diff --git a/package-lock.json b/package-lock.json index a17b83ab5..d9d03f465 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15932,18 +15932,6 @@ "node": ">=0.10.0" } }, - "node_modules/default-shell": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz", - "integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -18545,35 +18533,6 @@ "dev": true, "license": "MIT" }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -21859,18 +21818,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -22582,15 +22529,6 @@ "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -23319,6 +23257,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -26558,6 +26497,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -27464,18 +27404,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -27701,6 +27629,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -31124,50 +31053,6 @@ "node": ">=8" } }, - "node_modules/shell-env": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.3.tgz", - "integrity": "sha512-Ioe5h+hCDZ7pKL5+JGzbtPvZ5ESMHePZ8nLxohlDL+twmlcmutttMhRkrQOed8DeLT8mkYBgbwZfohe8pqaA3g==", - "license": "MIT", - "dependencies": { - "default-shell": "^2.0.0", - "execa": "^5.1.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/shell-env/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/shell-env/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -32100,15 +31985,6 @@ "node": ">=4" } }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/strip-indent": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", @@ -35510,7 +35386,6 @@ "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", - "shell-env": "^4.0.3", "sherpa-onnx": "1.12.28", "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", diff --git a/packages/desktop/src/login-shell-env.ts b/packages/desktop/src/login-shell-env.ts new file mode 100644 index 000000000..448ed3c24 --- /dev/null +++ b/packages/desktop/src/login-shell-env.ts @@ -0,0 +1,113 @@ +// Shell environment resolution adapted from VS Code +// https://github.com/microsoft/vscode/blob/main/src/vs/platform/shell/node/shellEnv.ts +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { userInfo } from "node:os"; +import { basename } from "node:path"; + +const RESOLVE_TIMEOUT_MS = 10_000; + +function getSystemShell(): string { + const shell = process.env.SHELL; + if (shell) return shell; + + try { + const info = userInfo(); + if (info.shell && info.shell !== "/bin/false") return info.shell; + } catch {} + + return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"; +} + +function resolveShellEnv(): Record | undefined { + if (process.platform === "win32") return undefined; + + const savedRunAsNode = process.env.ELECTRON_RUN_AS_NODE; + const savedNoAttach = process.env.ELECTRON_NO_ATTACH_CONSOLE; + + const mark = randomUUID().replace(/-/g, "").slice(0, 12); + const regex = new RegExp(mark + "({.*})" + mark); + + const shell = getSystemShell(); + const name = basename(shell); + + let command: string; + let shellArgs: string[]; + + if (/^(?:pwsh|powershell)(?:-preview)?$/.test(name)) { + command = `& '${process.execPath}' -p '''${mark}'' + JSON.stringify(process.env) + ''${mark}'''`; + shellArgs = ["-Login", "-Command"]; + } else if (name === "nu") { + command = `^'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; + shellArgs = ["-i", "-l", "-c"]; + } else if (name === "xonsh") { + command = `import os, json; print("${mark}", json.dumps(dict(os.environ)), "${mark}")`; + shellArgs = ["-i", "-l", "-c"]; + } else { + command = `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; + if (name === "tcsh" || name === "csh") { + shellArgs = ["-ic"]; + } else { + shellArgs = ["-i", "-l", "-c"]; + } + } + + const result = spawnSync(shell, [...shellArgs, command], { + encoding: "utf8", + timeout: RESOLVE_TIMEOUT_MS, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + ELECTRON_NO_ATTACH_CONSOLE: "1", + }, + }); + + if (result.status !== 0 && result.status !== null) return undefined; + if (!result.stdout) return undefined; + + const match = regex.exec(result.stdout); + if (!match?.[1]) return undefined; + + try { + const env = JSON.parse(match[1]) as Record; + + if (savedRunAsNode) { + env.ELECTRON_RUN_AS_NODE = savedRunAsNode; + } else { + delete env.ELECTRON_RUN_AS_NODE; + } + + if (savedNoAttach) { + env.ELECTRON_NO_ATTACH_CONSOLE = savedNoAttach; + } else { + delete env.ELECTRON_NO_ATTACH_CONSOLE; + } + + delete env.XDG_RUNTIME_DIR; + + return env; + } catch { + return undefined; + } +} + +/** + * On macOS/Linux, Electron inherits a minimal environment when launched from + * Finder/Dock. Spawn the user's login shell and capture its full environment + * via Node's JSON.stringify(process.env), so the daemon and all child processes + * see the same tools and variables as a normal terminal session. + * + * Approach borrowed from VS Code (src/vs/platform/shell/node/shellEnv.ts). + */ +export function inheritLoginShellEnv(): void { + try { + const env = resolveShellEnv(); + if (env) { + Object.assign(process.env, env); + } + } catch { + // Keep inherited environment if shell lookup fails. + } +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 70f3f5489..d5e4106c5 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -1,6 +1,9 @@ import log from "electron-log/main"; log.initialize({ spyRendererConsole: true }); +import { inheritLoginShellEnv } from "./login-shell-env.js"; +inheritLoginShellEnv(); + import path from "node:path"; import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; diff --git a/packages/server/package.json b/packages/server/package.json index adb0968a6..25dea5f01 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -86,7 +86,6 @@ "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", - "shell-env": "^4.0.3", "sherpa-onnx": "1.12.28", "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 8cb7aca71..4d96d37b9 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -66,7 +66,7 @@ describe("applyProviderEnv", () => { }, }; - const env = applyProviderEnv(base, runtime, {}); + const env = applyProviderEnv(base, runtime); expect(env.PATH).toBe("/usr/bin"); expect(env.HOME).toBe("/custom/home"); @@ -74,21 +74,11 @@ describe("applyProviderEnv", () => { expect(Object.keys(env).length).toBeGreaterThanOrEqual(3); }); - test("shell env PATH wins over base env PATH", () => { - const base = { PATH: "/usr/bin:/bin" }; - const shellEnv = { PATH: "/usr/local/bin:/usr/bin:/bin:/home/user/.nvm/bin" }; - - const env = applyProviderEnv(base, undefined, shellEnv); - - expect(env.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/home/user/.nvm/bin"); - }); - - test("runtimeSettings env wins over shell env", () => { + test("runtimeSettings env wins over base env", () => { const base = { PATH: "/usr/bin" }; - const shellEnv = { PATH: "/usr/local/bin:/usr/bin" }; const runtime: ProviderRuntimeSettings = { env: { PATH: "/custom/path" } }; - const env = applyProviderEnv(base, runtime, shellEnv); + const env = applyProviderEnv(base, runtime); expect(env.PATH).toBe("/custom/path"); }); @@ -103,7 +93,7 @@ describe("applyProviderEnv", () => { CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: "true", }; - const env = applyProviderEnv(base, undefined, {}); + const env = applyProviderEnv(base); expect(env.PATH).toBe("/usr/bin"); expect(env.CLAUDECODE).toBeUndefined(); diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index facd7671a..730b591f2 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import { - isCommandAvailable, - resolveShellEnv, -} from "../../utils/executable.js"; +import { isCommandAvailable } from "../../utils/executable.js"; import type { AgentProvider } from "./agent-sdk-types.js"; import { AgentProviderSchema } from "./provider-manifest.js"; @@ -94,11 +91,9 @@ const PARENT_SESSION_ENV_VARS = [ export function applyProviderEnv( baseEnv: Record, runtimeSettings?: ProviderRuntimeSettings, - shellEnv?: Record, ): Record { const merged: Record = { ...baseEnv, - ...(shellEnv ?? resolveShellEnv()), ...(runtimeSettings?.env ?? {}), }; for (const key of PARENT_SESSION_ENV_VARS) { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 37812924f..3745731ca 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -6,7 +6,7 @@ import { open as openFile, stat as statFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; -import { findExecutable, resolveShellEnv } from "./executable.js"; +import { findExecutable } from "./executable.js"; import { isPaseoOwnedWorktreeCwd } from "./worktree.js"; import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; @@ -1845,7 +1845,7 @@ export async function createPullRequest( await execAsync(`git push -u origin ${head}`, { cwd }); - const ghEnv = { ...resolveShellEnv(), GIT_TERMINAL_PROMPT: "0" }; + const ghEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`]; args.push("-f", `head=${head}`); args.push("-f", `base=${normalizedBase}`); @@ -1912,7 +1912,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise[ function createFindExecutableDependencies(): FindExecutableDependencies { return { execFileSync: vi.fn(), - execSync: vi.fn(), existsSync: vi.fn(), platform: vi.fn(() => "darwin"), - shell: undefined, }; } @@ -77,25 +75,25 @@ describe("findExecutable", () => { expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex"); }); - test("uses the last line from login-shell which output", () => { - findExecutableDependencies.shell = "/bin/zsh"; - findExecutableDependencies.execSync.mockReturnValue( - "echo from profile\n/usr/local/bin/codex\n", + test("on Unix, uses the last line from which output", () => { + findExecutableDependencies.execFileSync.mockReturnValue( + "/usr/local/bin/codex\n", ); expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex"); - expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce(); - expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled(); + expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith( + "which", + ["codex"], + { encoding: "utf8" }, + ); }); test("warns and returns null when the final which line is not an absolute path", () => { - findExecutableDependencies.shell = "/bin/zsh"; - findExecutableDependencies.execSync.mockReturnValue("profile noise\ncodex\n"); findExecutableDependencies.execFileSync.mockReturnValue("codex\n"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(findExecutable("codex", findExecutableDependencies)).toBeNull(); - expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledOnce(); warnSpy.mockRestore(); }); diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts index 4c5fd61a8..57bf5162d 100644 --- a/packages/server/src/utils/executable.ts +++ b/packages/server/src/utils/executable.ts @@ -1,15 +1,11 @@ -import { execFileSync, execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { platform } from "node:os"; import path from "node:path"; -import { shellEnvSync } from "shell-env"; - export interface FindExecutableDependencies { - execSync: typeof execSync; execFileSync: typeof execFileSync; existsSync: typeof existsSync; platform: typeof platform; - shell: string | undefined; } function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] { @@ -42,7 +38,7 @@ function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] { function resolveExecutableFromWhichOutput( name: string, output: string, - source: "login-shell" | "which", + source: "which", ): string | null { const lines = output .split(/\r?\n/) @@ -65,10 +61,8 @@ function resolveExecutableFromWhichOutput( } /** - * On Unix we first try `$SHELL -lic "which "` so that rc-file PATH - * additions (asdf, nvm, homebrew, nix, etc.) are visible — exactly as if the - * user opened a terminal and typed the command. If that fails (e.g. the login - * shell itself errors) we fall back to a plain `which`. + * On Unix we use plain `which` — the daemon's process.env.PATH is enriched + * with the login shell environment at Electron desktop startup. * * On Windows we augment the daemon PATH with machine/user registry PATH values * and return the first `where.exe` match. Launch-time execution decides whether @@ -85,11 +79,9 @@ export function findExecutable( } const deps: FindExecutableDependencies = { - execSync, execFileSync, existsSync, platform, - shell: process.env["SHELL"], ...dependencies, }; @@ -125,25 +117,6 @@ export function findExecutable( } } - // Unix: try the user's login shell so rc-file PATH entries are visible. - const shell = deps.shell; - if (shell) { - try { - const out = deps - .execSync(`${shell} -lic "which ${trimmed}"`, { - encoding: "utf8", - timeout: 5000, - }) - .trim(); - const resolved = resolveExecutableFromWhichOutput(trimmed, out, "login-shell"); - if (resolved) { - return resolved; - } - } catch { - // Login shell failed (broken rc, etc.) — fall through to plain which. - } - } - try { return resolveExecutableFromWhichOutput( trimmed, @@ -184,14 +157,3 @@ export function quoteWindowsArgument(argument: string): string { return `"${argument}"`; } -let cachedShellEnv: Record | null = null; - -export function resolveShellEnv(): Record { - if (cachedShellEnv) return cachedShellEnv; - try { - cachedShellEnv = shellEnvSync(); - } catch { - cachedShellEnv = { ...process.env } as Record; - } - return cachedShellEnv; -}