fix(desktop): find npx when a Dock-launched agent starts MCP servers

This commit is contained in:
tianma-if committed 2026-10-21 18:09:48 +08:00
1 parent 5d1a0661d1
commit 55d7e48225
2 files changed
+150 -6

No files matched your search

+87 -6
View File
@@ -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,
},
};
}
+63
View File
@@ -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);