From 55d7e4822574310854f9f3fe0fa4d8d79a073615 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 21 Oct 2026 18:09:48 +0800 Subject: [PATCH] fix(desktop): find npx when a Dock-launched agent starts MCP servers --- apps/desktop/src/main/acp-host.mjs | 93 +++++++++++++++++++++++-- apps/desktop/src/main/acp-host.test.mjs | 63 +++++++++++++++++ 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/acp-host.mjs b/apps/desktop/src/main/acp-host.mjs index ec7d97f2..60d93101 100644 --- a/apps/desktop/src/main/acp-host.mjs +++ b/apps/desktop/src/main/acp-host.mjs @@ -516,7 +516,81 @@ export function acpInitializeParams(version = clientVersion()) { }; } -export function createAcpSpawnPlan(input, cwd) { +// Dock, Finder, and Start Menu launches give the desktop process a minimal PATH. +// Agents then fail user MCP configs whose command is a bare `npx` or `npm`. +const desktopToolDirectories = (platform, home, env) => { + const join = platform === "win32" ? path.win32.join : path.posix.join; + const volta = join(home, ".volta", "bin"); + const asdf = join(home, ".asdf", "shims"); + const mise = join(home, ".local", "share", "mise", "shims"); + const fnm = join(home, ".fnm", "aliases", "default", "bin"); + if (platform === "win32") { + return [ + env.APPDATA && join(env.APPDATA, "npm"), + env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Volta", "bin"), + volta, + env.PROGRAMFILES && join(env.PROGRAMFILES, "nodejs"), + env.LOCALAPPDATA && join(env.LOCALAPPDATA, "fnm", "aliases", "default", "bin"), + ].filter(Boolean); + } + return [ + join(home, ".local", "bin"), + platform === "darwin" ? "/opt/homebrew/bin" : null, + "/usr/local/bin", + volta, + asdf, + fnm, + platform === "darwin" + ? join(home, "Library", "Application Support", "fnm", "aliases", "default", "bin") + : join(home, ".local", "share", "fnm", "aliases", "default", "bin"), + mise, + ].filter(Boolean); +}; + +const nvmDefaultBin = (home, exists, readFile) => { + let version = ""; + try { + version = String(readFile(path.posix.join(home, ".nvm", "alias", "default")) ?? "").trim(); + } catch { + return null; + } + if (!/^v\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?$/.test(version)) return null; + const bin = path.posix.join(home, ".nvm", "versions", "node", version, "bin"); + try { + return exists(bin) ? bin : null; + } catch { + return null; + } +}; + +export function desktopToolPath({ + platform = process.platform, + home = homedir(), + pathEnv = "", + env = {}, + exists = existsSync, + readFile = (file) => readFileSync(file, "utf8"), +} = {}) { + const delimiter = platform === "win32" ? ";" : ":"; + const current = String(pathEnv).split(delimiter).filter(Boolean); + const seen = new Set(platform === "win32" ? current.map((dir) => dir.toLowerCase()) : current); + const extras = []; + const add = (dir) => { + if (!dir) return; + const key = platform === "win32" ? dir.toLowerCase() : dir; + if (seen.has(key)) return; + let present = false; + try { present = exists(dir) === true; } catch { present = false; } + if (!present) return; + extras.push(dir); + seen.add(key); + }; + for (const dir of desktopToolDirectories(platform, home, env)) add(dir); + if (platform !== "win32") add(nvmDefaultBin(home, exists, readFile)); + return [...extras, ...current].join(delimiter); +} + +export function createAcpSpawnPlan(input, cwd, deps = {}) { const spec = typeof input === "string" ? { command: input, args: [] } : input; const command = spec?.command; if (typeof command !== "string" || !command.trim() || command.includes("\0")) throw new Error("invalid_command"); @@ -524,6 +598,17 @@ export function createAcpSpawnPlan(input, cwd) { if (typeof cwd !== "string" || !cwd.trim()) throw new Error("invalid_workspace"); const workspace = path.resolve(cwd); if (workspace === path.resolve(homedir())) throw new Error("invalid_workspace"); + const env = { ...(deps.env ?? process.env), ...spec.env }; + for (const key of spec.unsetEnv ?? []) delete env[key]; + const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"; + env[pathKey] = desktopToolPath({ + platform: deps.platform ?? process.platform, + home: deps.home ?? homedir(), + pathEnv: env[pathKey] ?? "", + env, + exists: deps.exists ?? existsSync, + readFile: deps.readFile ?? ((file) => readFileSync(file, "utf8")), + }); return { command, args: spec.args, @@ -532,11 +617,7 @@ export function createAcpSpawnPlan(input, cwd) { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, shell: false, - ...(spec.env || spec.unsetEnv ? { env: (() => { - const env = { ...process.env, ...spec.env }; - for (const key of spec.unsetEnv ?? []) delete env[key]; - return env; - })() } : {}), + env, }, }; } diff --git a/apps/desktop/src/main/acp-host.test.mjs b/apps/desktop/src/main/acp-host.test.mjs index 6a3b03e2..089883ea 100644 --- a/apps/desktop/src/main/acp-host.test.mjs +++ b/apps/desktop/src/main/acp-host.test.mjs @@ -14,6 +14,7 @@ import { classifyAcpFailure, createAcpHostRuntime, createAcpSpawnPlan, + desktopToolPath, detectInstalledAgentApps, eventsFromSessionUpdate, isAuthRequiredError, @@ -592,6 +593,68 @@ describe("ACP spawn and failure mapping", () => { await rm(workspace, { recursive: true, force: true }); }); + test("adds node tool directories so a Dock-launched agent can exec npx", async () => { + const workspace = await mkdtemp(path.join(tmpdir(), "edgeever-acp-path-")); + const homeDir = "/Users/example"; + const nvmBin = path.join(homeDir, ".nvm", "versions", "node", "v22.14.0", "bin"); + const present = new Set(["/opt/homebrew/bin", path.join(homeDir, ".volta", "bin"), nvmBin]); + const deps = { + platform: "darwin", + home: homeDir, + env: { PATH: "/usr/bin:/bin", HOME: homeDir }, + exists: (dir) => present.has(dir), + readFile: (file) => { + if (file === path.join(homeDir, ".nvm", "alias", "default")) return "v22.14.0\n"; + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }, + }; + try { + const plan = createAcpSpawnPlan("/Applications/Antigravity.app/Contents/MacOS/Antigravity", workspace, deps); + expect(plan.options.shell).toBe(false); + expect(plan.options.env.PATH).toBe(`/opt/homebrew/bin:${path.join(homeDir, ".volta", "bin")}:${nvmBin}:/usr/bin:/bin`); + const narrowed = createAcpSpawnPlan({ + command: "/bin/openclaw", + args: ["acp"], + env: { PATH: "/custom/bin:/opt/homebrew/bin" }, + }, workspace, deps); + expect(narrowed.options.env.PATH).toBe(`${path.join(homeDir, ".volta", "bin")}:${nvmBin}:/custom/bin:/opt/homebrew/bin`); + expect(desktopToolPath({ + platform: "darwin", + home: homeDir, + pathEnv: "/usr/bin", + exists: () => true, + readFile: () => "lts/*", + })).toBe([ + path.join(homeDir, ".local", "bin"), + "/opt/homebrew/bin", + "/usr/local/bin", + path.join(homeDir, ".volta", "bin"), + path.join(homeDir, ".asdf", "shims"), + path.join(homeDir, ".fnm", "aliases", "default", "bin"), + path.join(homeDir, "Library", "Application Support", "fnm", "aliases", "default", "bin"), + path.join(homeDir, ".local", "share", "mise", "shims"), + "/usr/bin", + ].join(":")); + expect(desktopToolPath({ + platform: "win32", + home: "C:\\Users\\example", + pathEnv: "C:\\Windows\\System32", + env: { APPDATA: "C:\\Users\\example\\AppData\\Roaming", PROGRAMFILES: "C:\\Program Files" }, + exists: (dir) => dir === "C:\\Program Files\\nodejs", + readFile: () => { throw new Error("ENOENT"); }, + })).toBe("C:\\Program Files\\nodejs;C:\\Windows\\System32"); + expect(desktopToolPath({ + platform: "darwin", + home: homeDir, + pathEnv: "/usr/bin", + exists: () => false, + readFile: () => "../v1.2.3", + })).toBe("/usr/bin"); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); + test("maps auth, missing binaries, and strips home paths from details", () => { expect(isAuthRequiredError(RequestError.authRequired())).toBe(true); expect(isAuthRequiredError(new Error("session failed: auth_required"))).toBe(true);