Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit f3ea02c

Browse files
authored
Merge pull request #56 from closedloop-ai/fix/shell-path-resolution
fix/shell-path-resolution: extract shared shell PATH resolver for CLI spawns
2 parents df86d9c + ece8436 commit f3ea02c

21 files changed

Lines changed: 367 additions & 64 deletions

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.9.3",
3+
"version": "0.9.5",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,

apps/desktop/src/server/operations/codex.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
44
import path from "node:path";
55
import type { ServerResponse } from "node:http";
66
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
7+
import { getShellPath } from "../shell-path.js";
78
import { DirectoryNotAllowedError } from "../security.js";
89
import { ENGINEER_CHAT_TOOLS, withMcpTools } from "./chat-tools.js";
910
import { loadJsonFile, saveJsonFile } from "./chat-history-store.js";
@@ -1158,7 +1159,7 @@ export function registerCodexRoutes(
11581159
stdio: ["pipe", "pipe", "pipe"],
11591160
env: {
11601161
...process.env,
1161-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`
1162+
PATH: await getShellPath()
11621163
}
11631164
});
11641165

@@ -1549,7 +1550,8 @@ function similarityScore(messageA: string, messageB: string, fileA: string, file
15491550
return Math.min(score, 1);
15501551
}
15511552

1552-
function spawnClaudeReview(cwd: string, model: string): ChildProcess {
1553+
async function spawnClaudeReview(cwd: string, model: string): Promise<ChildProcess> {
1554+
const shellPath = await getShellPath();
15531555
return spawn(
15541556
"claude",
15551557
[
@@ -1570,7 +1572,7 @@ function spawnClaudeReview(cwd: string, model: string): ChildProcess {
15701572
stdio: ["pipe", "pipe", "pipe"],
15711573
env: {
15721574
...process.env,
1573-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`
1575+
PATH: shellPath
15741576
}
15751577
}
15761578
);
@@ -1587,7 +1589,7 @@ async function resolveClaudeReviewProcess(
15871589
prNum: string,
15881590
logPath: string
15891591
): Promise<ChildProcess> {
1590-
const first = spawnClaudeReview(cwd, model);
1592+
const first = await spawnClaudeReview(cwd, model);
15911593
first.stdin?.write("/code-review:start");
15921594
first.stdin?.end();
15931595

@@ -1659,7 +1661,7 @@ async function resolveClaudeReviewProcess(
16591661
// Skill exited without producing review content — fall back to /review <prNum>
16601662
await fs.writeFile(logPath, "", "utf-8");
16611663

1662-
const fallback = spawnClaudeReview(cwd, model);
1664+
const fallback = await spawnClaudeReview(cwd, model);
16631665
fallback.stdin?.write(`/review ${prNum}`);
16641666
fallback.stdin?.end();
16651667
return fallback;
@@ -2085,12 +2087,13 @@ async function waitForExit(child: ChildProcess): Promise<number> {
20852087
}
20862088

20872089
async function runCommand(command: string, args: string[]): Promise<string> {
2090+
const shellPath = await getShellPath();
20882091
return await new Promise((resolve, reject) => {
20892092
const child = spawn(command, args, {
20902093
stdio: ["ignore", "pipe", "pipe"],
20912094
env: {
20922095
...process.env,
2093-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`
2096+
PATH: shellPath
20942097
}
20952098
});
20962099

@@ -2369,11 +2372,12 @@ function runCodexVerdict(worktreeDir: string, sessionId: string): Promise<string
23692372
);
23702373
}
23712374

2372-
function runClaudeVerdict(worktreeDir: string, sessionId: string): Promise<string> {
2375+
async function runClaudeVerdict(worktreeDir: string, sessionId: string): Promise<string> {
2376+
const shellPath = await getShellPath();
23732377
return runVerdictProcess(
23742378
"claude",
23752379
["-p", "--resume", sessionId, "--output-format", "stream-json", "--model", "sonnet", "--allowedTools", "Read,Glob,Grep"],
2376-
{ cwd: worktreeDir, stdin: VERDICT_PROMPT, env: { PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin` } },
2380+
{ cwd: worktreeDir, stdin: VERDICT_PROMPT, env: { PATH: shellPath } },
23772381
extractClaudeVerdictLine
23782382
);
23792383
}

apps/desktop/src/server/operations/deploy.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
44
import path from "node:path";
55
import net from "node:net";
66
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
7+
import { getShellPath } from "../shell-path.js";
78
import { DirectoryNotAllowedError, assertPathAllowed } from "../security.js";
89
import {
910
loadReposConfig,
@@ -97,7 +98,7 @@ export function registerDeployRoutes(
9798
copyEnvLocalFiles(expandedRepoPath, expandedWorktreePath).catch(() => undefined);
9899

99100
const spawnEnv: NodeJS.ProcessEnv = {
100-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`,
101+
PATH: await getShellPath(),
101102
HOME: process.env.HOME,
102103
USER: process.env.USER,
103104
SHELL: process.env.SHELL ?? "/bin/bash",
@@ -304,7 +305,7 @@ export function registerDeployRoutes(
304305
}
305306

306307
if (deployConfig?.teardownCommand) {
307-
if (runTeardownCommand(deployConfig.teardownCommand, expandedWorktreePath)) {
308+
if (await runTeardownCommand(deployConfig.teardownCommand, expandedWorktreePath)) {
308309
json(context, 200, { success: true });
309310
return;
310311
}
@@ -875,7 +876,7 @@ function killByPort(port: number): "killed" | "none" | "error" {
875876
}
876877
}
877878

878-
function runTeardownCommand(command: string, worktreePath: string): boolean {
879+
async function runTeardownCommand(command: string, worktreePath: string): Promise<boolean> {
879880
try {
880881
execSync(command, {
881882
cwd: worktreePath,
@@ -884,7 +885,7 @@ function runTeardownCommand(command: string, worktreePath: string): boolean {
884885
stdio: "pipe",
885886
env: {
886887
...process.env,
887-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`
888+
PATH: await getShellPath()
888889
}
889890
});
890891
return true;

apps/desktop/src/server/operations/git-pr.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execFile, spawn, spawnSync } from "node:child_process";
22
import { promisify } from "node:util";
33
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
4+
import { getShellPath } from "../shell-path.js";
45
import { DirectoryNotAllowedError } from "../security.js";
56
import { assertRepoAllowed } from "./symphony-utils.js";
67

@@ -298,7 +299,7 @@ export function registerGitPrRoutes(
298299
{
299300
cwd,
300301
encoding: "utf-8",
301-
env: withPathEnv()
302+
env: await withPathEnv()
302303
}
303304
);
304305

@@ -772,7 +773,7 @@ async function runRead(
772773
const { stdout } = await execFileAsync(command, args, {
773774
cwd,
774775
encoding: "utf-8",
775-
env: withPathEnv()
776+
env: await withPathEnv()
776777
});
777778
return stdout.trim();
778779
}
@@ -781,26 +782,27 @@ async function run(cwd: string | undefined, command: string, args: string[]): Pr
781782
await execFileAsync(command, args, {
782783
cwd,
783784
encoding: "utf-8",
784-
env: withPathEnv()
785+
env: await withPathEnv()
785786
});
786787
}
787788

788-
function withPathEnv(): NodeJS.ProcessEnv {
789+
async function withPathEnv(): Promise<NodeJS.ProcessEnv> {
789790
return {
790791
...process.env,
791-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`
792+
PATH: await getShellPath()
792793
};
793794
}
794795

795-
function ghApiViaStdin(
796+
async function ghApiViaStdin(
796797
apiPath: string,
797798
payload: Record<string, unknown>,
798799
cwd: string
799800
): Promise<{ stdout: string; stderr: string }> {
801+
const env = await withPathEnv();
800802
return new Promise((resolve, reject) => {
801803
const process = spawn("gh", ["api", apiPath, "--method", "POST", "--input", "-"], {
802804
cwd,
803-
env: withPathEnv()
805+
env
804806
});
805807

806808
let stdout = "";
@@ -828,15 +830,16 @@ function ghApiViaStdin(
828830
});
829831
}
830832

831-
function ghPrCommentViaStdin(
833+
async function ghPrCommentViaStdin(
832834
args: string[],
833835
body: string,
834836
cwd: string
835837
): Promise<{ stdout: string; stderr: string }> {
838+
const env = await withPathEnv();
836839
return new Promise((resolve, reject) => {
837840
const process = spawn("gh", [...args, "--body-file", "-"], {
838841
cwd,
839-
env: withPathEnv()
842+
env
840843
});
841844

842845
let stdout = "";

apps/desktop/src/server/operations/health-check.ts

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import { promisify } from "node:util";
55
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
6+
import { getShellPath } from "../shell-path.js";
67
import { isPluginInstalled } from "./plugin-cache.js";
78
import type { ProcessManager } from "../process-manager.js";
89

@@ -55,30 +56,6 @@ export function registerHealthCheckRoutes(
5556
});
5657
}
5758

58-
/**
59-
* Resolve the user's login-shell PATH.
60-
* Electron on macOS inherits a minimal PATH that excludes /opt/homebrew/bin,
61-
* nvm paths, etc. Spawning the user's shell with -ilc gives us the real PATH.
62-
*/
63-
let resolvedPathPromise: Promise<string> | undefined;
64-
async function getShellPath(): Promise<string> {
65-
if (resolvedPathPromise) {
66-
return resolvedPathPromise;
67-
}
68-
resolvedPathPromise = (async () => {
69-
try {
70-
const shell = process.env.SHELL || "/bin/zsh";
71-
const { stdout } = await execFileAsync(shell, ["-ilc", "echo $PATH"], {
72-
timeout: 3000,
73-
});
74-
return stdout.trim();
75-
} catch {
76-
return `${process.env.PATH ?? ""}:/opt/homebrew/bin:/usr/local/bin`;
77-
}
78-
})();
79-
return resolvedPathPromise;
80-
}
81-
8259
async function runCommand(_processManager: ProcessManager, cmd: string, args: string[]): Promise<string> {
8360
const shellPath = await getShellPath();
8461
const { stdout } = await execFileAsync(cmd, args, {

apps/desktop/src/server/operations/learnings.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
44
import os from "node:os";
55
import path from "node:path";
66
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
7+
import { getShellPath } from "../shell-path.js";
78
import { findPluginScript } from "./plugin-cache.js";
89
import { DirectoryNotAllowedError, assertPathAllowed } from "../security.js";
910
import { assertRepoAllowed, findFirstExisting, resolveWorktreeDir } from "./symphony-utils.js";
@@ -275,7 +276,7 @@ export function registerLearningsRoutes(
275276
cwd: worktreeDir,
276277
env: {
277278
...process.env,
278-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`,
279+
PATH: await getShellPath(),
279280
CLOSEDLOOP_WORKDIR: claudeWorkDir
280281
}
281282
});
@@ -413,7 +414,7 @@ export function registerLearningsRoutes(
413414
});
414415

415416
await fs.appendFile(outcomesPath, `${lines.join("\n")}\n`, "utf-8");
416-
triggerSuccessRateComputation(claudeWorkDir);
417+
void triggerSuccessRateComputation(claudeWorkDir);
417418

418419
json(context, 200, {
419420
status: "recorded",
@@ -439,7 +440,7 @@ function parseToon(content: string): ParsedLearningPattern[] {
439440
});
440441
}
441442

442-
function triggerSuccessRateComputation(workDir: string): void {
443+
async function triggerSuccessRateComputation(workDir: string): Promise<void> {
443444
const runLoopPath = findPluginScript("code", "run-loop.sh");
444445
if (!runLoopPath) {
445446
return;
@@ -451,12 +452,13 @@ function triggerSuccessRateComputation(workDir: string): void {
451452
return;
452453
}
453454

455+
const shellPath = await getShellPath();
454456
const child = spawn("python3", [ratesScript, "--workdir", workDir], {
455457
stdio: "ignore",
456458
detached: true,
457459
env: {
458460
...process.env,
459-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`
461+
PATH: shellPath
460462
}
461463
});
462464
child.unref();

apps/desktop/src/server/operations/run-viewer-chat.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "node:path";
33
import type { ServerResponse } from "node:http";
44
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
55
import type { ProcessManager } from "../process-manager.js";
6+
import { getShellEnv } from "../shell-path.js";
67
import { DirectoryNotAllowedError, assertPathAllowed } from "../security.js";
78
import { loadJsonFile, saveJsonFile } from "./chat-history-store.js";
89
import { READONLY_CODEBASE_TOOLS, WEB_ONLY_TOOLS } from "./chat-tools.js";
@@ -83,6 +84,8 @@ export function registerRunViewerChatRoutes(
8384
setStreamingHeaders(context.response);
8485
writeEvent(context.response, { type: "status", status: "spawning", mode: "claude" });
8586

87+
const shellEnv = await getShellEnv();
88+
8689
await new Promise<void>((resolve) => {
8790
const streamState = createStreamState(async (sessionId) => {
8891
if (!history.claudeSessionId) {
@@ -127,6 +130,7 @@ export function registerRunViewerChatRoutes(
127130
: [])
128131
],
129132
cwd: validatedRunDir ?? os.homedir(),
133+
env: shellEnv,
130134
input: prompt,
131135
onLine: (line) => {
132136
try {

apps/desktop/src/server/operations/symphony-interactive.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
OperationDispatcher,
88
OperationRequestContext,
99
} from "../operation-dispatcher.js";
10+
import { getShellPath } from "../shell-path.js";
1011
import { assertPathAllowed, DirectoryNotAllowedError } from "../security.js";
1112
import { loadJsonFile, saveJsonFile } from "./chat-history-store.js";
1213
import { ENGINEER_CHAT_TOOLS, withMcpTools } from "./chat-tools.js";
@@ -702,7 +703,7 @@ export function registerSymphonyInteractiveRoutes(
702703
env: {
703704
...process.env,
704705
CLOSEDLOOP_WORKDIR: claudeWorkDir,
705-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`,
706+
PATH: await getShellPath(),
706707
},
707708
});
708709
child.unref();
@@ -774,7 +775,7 @@ async function streamClaudeChat(options: {
774775
stdio: ["pipe", "pipe", "pipe"],
775776
env: {
776777
...process.env,
777-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`,
778+
PATH: await getShellPath(),
778779
},
779780
}
780781
);
@@ -990,11 +991,12 @@ function getGitDiff(worktreeDir: string): string {
990991
}
991992
}
992993

993-
function generateCommitWithClaude(
994+
async function generateCommitWithClaude(
994995
worktreeDir: string,
995996
ticketId: string,
996997
diff: string
997998
): Promise<{ title: string; description: string }> {
999+
const shellPath = await getShellPath();
9981000
return new Promise((resolve, reject) => {
9991001
const prompt = [
10001002
`Generate a git commit message for ticket ${ticketId}.`,
@@ -1015,7 +1017,7 @@ function generateCommitWithClaude(
10151017
stdio: ["ignore", "pipe", "pipe"],
10161018
env: {
10171019
...process.env,
1018-
PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`,
1020+
PATH: shellPath,
10191021
},
10201022
});
10211023

0 commit comments

Comments
 (0)