fix(desktop): find npx when a Dock-launched agent starts MCP servers
This commit is contained in:
1 parent
5d1a0661d1
commit
55d7e48225
2 files changed
+150
-6
No files matched your search
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user