Repository navigation
fix(installer): pin absolute node + cli.js in global MCP entries - #1344
blazejkustra wants to merge 8 commits into
Conversation
Editors launched outside a login shell (WebStorm's ACP runtime, Dock-launched apps) inherit a PATH without nvm/Homebrew, so the bare `argent` command was ENOENT. Global-scope entries of a global install now name the node beside the global bin link (or the running node) and the absolute cli.js; project files keep the portable bare command. Co-Authored-By: Claude <noreply@anthropic.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| // node. | ||
| export function resolveGlobalCommandMode(): McpCommandMode { | ||
| const launch = getGloballyInstalledLaunchPaths(); | ||
| const node = launch?.node ?? process.execPath; |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: When the prefix holding argent has no node beside its bin link, this falls back to process.execPath. That covers a custom npm prefix such as ~/.npm-global (npm's documented EACCES fix), yarn v1 global and bun global. Node reports execPath as the resolved real path, which under Homebrew is the versioned keg: on this machine /opt/homebrew/bin/node -p process.execPath prints /opt/homebrew/Cellar/node/26.10.0_1/bin/node.
brew upgrade node runs brew cleanup by default and deletes that keg. The pinned global entry then ENOENTs, including in clients started from a terminal, where main's bare argent keeps working after the upgrade.
This contradicts two claims:
- The
getGloballyInstalledLaunchPathsJSDoc (topology.ts:88-93) says the Cellar path is exactly what it avoids. - mcp-server.mdx:33 says the config keeps working across Node versions. Under nvm, the same fallback pins the exact version that ran
init.
The shape is now argent-managed, so argent update also rewrites a hand-written workaround that used the stable /opt/homebrew/bin/node to the Cellar path.
Ran: the PR's resolveGlobalCommandMode() under /opt/homebrew/bin/node against a custom-prefix layout (prefix/bin/argent -> lib/node_modules/@swmansion/argent/dist/cli.js, no bin/node). It returns node: "/opt/homebrew/Cellar/node/26.10.0_1/bin/node".
There was a problem hiding this comment.
Leaving open: falling back to bare argent loses the fix for custom-prefix users, and pinning which node instead still pins a version under nvm. This conflicts with the sibling-node version thread and needs the author's call on which node to pin.
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: Still present as of bd331df, now through nodeBeside instead of process.execPath. f36f621 picks the PATH node by name, but nodeBeside realpaths its directory first (topology.ts:128). Any PATH entry that is a symlinked dir therefore still resolves to the versioned keg. That covers:
- keg-only
node@22/node@20, whose caveat says to put/opt/homebrew/opt/node@22/binon PATH. The formula's npmrc setsprefix = HOMEBREW_PREFIX, so the global argent lands in/opt/homebrew/binwith nonodebeside it, and the fallback runs. /opt/homebrew/opt/node/binon PATH with a custom npm prefix.
The entry pins /opt/homebrew/Cellar/node@22/<exact>/bin/node. The next brew upgrade node@22 deletes that keg in its default cleanup, and from then on the entry fails in every client, terminal-launched ones included. The merge-base's bare argent keeps working there. The JSDoc at topology.ts:106-114 says the bin dir's name is kept to avoid exactly this path.
What I ran:
- The real Homebrew node (
/opt/homebrew/opt/node -> ../Cellar/node/26.10.0_1) and a custom prefix holding this head's package, nobin/node, through the head'sresolveGlobalCommandMode():PATH=<prefix>/bin:/opt/homebrew/opt/node/bin:/usr/bin:/binreturnednode: "/opt/homebrew/Cellar/node/26.10.0_1/bin/node".PATH=<prefix>/bin:/opt/homebrew/bin:/usr/bin:/binreturned/opt/homebrew/bin/node.
- An emulated keg-only layout (
Cellar/node@22/22.1.0/bin/node,opt/node@22 -> ../Cellar/node@22/22.1.0,bin/argent-> cli.js, nobin/node), using the realargent init --yes --global. The head wrotecommand: <brew>/Cellar/node@22/22.1.0/bin/nodeto Windsurf's config, and the merge-base wrote{"command":"argent","args":["mcp"]}. - Then I emulated the upgrade: a new 22.2.0 keg,
optrelinked, 22.1.0 removed. Spawning the head entry with the terminal PATH failed withspawn <brew>/Cellar/node@22/22.1.0/bin/node ENOENT. The bare entry answered MCPinitializewithserverInfo {"name":"argent","version":"0.30.1"}.
| const node = path.join( | ||
| path.dirname(binaryPath), | ||
| process.platform === "win32" ? "node.exe" : "node" | ||
| ); | ||
| return { cli, node: fs.existsSync(node) ? node : null }; |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: Under fnm, which -a argent returns a path inside fnm's per-shell multishell dir: $XDG_RUNTIME_DIR/fnm_multishells/<pid>_<ts>/bin/argent, or ~/.local/state/fnm_multishells/... without XDG_RUNTIME_DIR. cli is realpathed through packageRootOfBinary, but node is joined onto the unresolved dirname(binaryPath), so the entry pins <multishell>/bin/node.
- Linux: that runtime dir is a logind tmpfs cleared at logout and reboot, after which the entry ENOENTs. Main's bare
argentkeeps working there for terminal-launched clients, because every new fnm shell puts a fresh multishell on PATH. - macOS, or no
XDG_RUNTIME_DIR: the pinned path is the live symlink of the shell that raninit. A laterfnm usein that shell, or--use-on-cdentering a project with an.nvmrc, silently switches the MCP server to that Node.
The JSDoc above calls this node the prefix's stable name, and mcp-server.mdx:33 promises the config survives Node switches.
mise in shims mode has the same problem in another form. init and update run through a mise shim, which puts installs/node/<exact>/bin first on PATH, so this pins node and cli.js under installs/node/24.14.1/. mise deletes that dir by itself: upgrade.auto_prune defaults to true with prune_after 24h, and mise upgrade --help warns to pass --no-prune when something outside mise points at the install dir. So the entry goes dead about a day after a routine mise upgrade node, while main's bare argent keeps resolving through the shims dir.
Ran: the real fnm 1.38.1 binary in a sandbox FNM_DIR, eval "$(fnm env)", then the PR's resolveGlobalCommandMode() + getMcpEntryForScope("global","global",...). The pinned command was .../fnm_multishells/1214_1791814454867/bin/node. After emptying the runtime dir: env: .../bin/node: No such file or directory (exit 127), while bare argent mcp from a fresh fnm shell ran. After fnm use v18.0.0 in the original shell, the same entry ran Node 18 against the v22 install's cli.js. Also ran the real mise 2026.10.3 in a sandbox MISE_DATA_DIR: through shims/node, which -a argent listed installs/node/24.14.1/bin/argent before shims/argent, and the global entry pinned installs/node/24.14.1/bin/node with that version's cli.js.
There was a problem hiding this comment.
Leaving open: realpathing the fnm multishell dir would pin a per-version path, and mise's auto-prune deletes that too. Which node path to pin under fnm/mise is a design decision for the author.
| const binaryPath = getGlobalBinaryPath(); | ||
| const packageRoot = packageRootOfBinary(binaryPath); | ||
| if (!binaryPath || !packageRoot) return null; |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: The first which -a argent hit that isn't a recognised temp runner isn't necessarily the global install. On main that misdetection only produced a bare argent entry, which still resolves the real global. Here it gets pinned into user-wide configs (~/.claude.json, ~/.cursor/mcp.json, Windsurf, ...). Two reproduced cases:
npx/npm exec. npm puts<cwd>/node_modules/.binand every ancestor'snode_modules/.binahead of the user's PATH, andTEMP_RUNNER_MARKERSdoesn't filter them. A project-local copy wins over the real global prefix, and so does a hoisted monorepo copy or a stray~/node_modulescopy. The documentednpx @swmansion/argent@latest initwith Global scope, run from such a dir, pins<node> <project>/node_modules/@swmansion/argent/dist/cli.js mcp. Every project then runs that repo's version, and argent fails to start everywhere once thatnode_modulesgoes away.updaterun from a dir with a stray ancestor copy also rewrites an already-correct pinned entry to it.bunxwith a version spec (bunx @swmansion/argent@latest init). bun installs into$TMPDIR/bunx-<uid>-<spec>/node_modulesand puts that.bin(a real symlink to cli.js) first. Thebun/install/cachemarker doesn't match it, so the global entry pins a cli.js in the OS temp dir, which temp cleaning later removes.
Ran (sandbox HOME, a fake global prefix, comparing this head with a merge-base dist):
- From a project with a
node_modules/.bin/argentdevDependency copy, under realnpx(npm 11.19),writeMcpConfigs({installMode: "global", scope: "global"}). This head wrote{"command":"/opt/homebrew/Cellar/node/26.10.0_1/bin/node","args":[".../proj/node_modules/@swmansion/argent/dist/cli.js","mcp"]}. The merge-base wrote{"command":"argent","args":["mcp"]}. After movingproj/node_modulesaway, the pinned entry failed withError: Cannot find module '.../proj/node_modules/@swmansion/argent/dist/cli.js'. - Real bun 1.3.10 running
init(["--yes","--global"])from a bunx-installed package. This head wrote the Windsurf entry as{"command":".../.nvm/versions/node/v24.14.1/bin/node","args":["/private/var/folders/.../T/bunx-501-.../node_modules/@swmansion/argent/dist/cli.js","mcp"]}. The merge-base wrote{"command":"argent","args":["mcp"]}.
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: Still present as of 2dde759. pnpm dlx is another runner this misses, with a different outcome:
- Current pnpm runs dlx from
~/.cache/pnpm/dlx/<hash>/<id>/node_modules/.bin/argent. The/dlx-marker only matches pnpm 8'sdlx-<pid>dirs, so it misses this path. - That
.bin/argentis a/bin/shshim.packageRootOfBinarytherefore climbs to the dlx dir'spackage.json(nameddlx) and returns null, even though a working symlinked global install sits right behind it on PATH. - So
pnpm dlx @swmansion/argent@latest init(installation.mdx:27) writes bareargentinto global configs, and aninitorupdaterun this way reverts an existing pinned entry.
What I ran: a real pnpm dlx (pnpm 12.11.2) of a probe package with an argent bin. which -a listed the dlx .bin/argent (a file) before ~/.nvm/versions/node/v24.14.1/bin/argent (a symlink). With that dlx .bin first on PATH, this head's writeMcpConfigs({installMode:"global", scope:"global"}) wrote {"command":"argent","args":["mcp"]} to ~/.claude.json. Without it, it wrote the pinned node + cli.js.
There was a problem hiding this comment.
Fixed in 1943480. Partial: current pnpm dlx (/pnpm/dlx/) and versioned bunx (/bunx-) dirs are now skipped as temp runners, with tests. The npx / ancestor node_modules/.bin case is still open, because filtering those out changes isGloballyInstalled() for every caller.
| } | ||
| ``` | ||
|
|
||
| If the editor shows `Executable not found in $PATH: argent`, the editor does not use your shell `PATH`. Run the wizard to write absolute paths. |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: This doesn't hold for global installs whose bin on PATH is a shim rather than a symlink into the package:
- Volta (
volta-shim) - pnpm global (a shell-script shim in
PNPM_HOME) - npm on Windows (the
argent/argent.cmdwrappers that topology.ts:74-77 describes)
For all of these, getGloballyInstalledLaunchPaths returns null and resolveGlobalCommandMode silently returns { kind: "global" }. The wizard writes bare argent again and prints the usual + <editor> <path> success line. Unlike the local-npx fallback, it gives no hint that pinning failed.
Volta and pnpm put their bin dir on PATH from a shell rc, so their users are the ones who see this error. Following this line rewrites the same entry and the ENOENT stays. Line 31 states the absolute-path behaviour unconditionally.
Ran: the PR's resolver against a real pnpm add -g layout (pnpm's /bin/sh shim), and under real Volta 2.0.2 shims in a sandbox VOLTA_HOME. Both return { kind: "global" }, and writeMcpConfigs writes {"command":"argent","args":["mcp"]} into the global config.
| // stdio server. global-node pins absolute node + cli.js paths: clients started | ||
| // outside a login shell (JetBrains ACP, GUI-launched editors) often inherit a | ||
| // PATH without the user's version manager, where a bare `argent` is ENOENT | ||
| // (#1287). Bare `argent` remains the fallback when the install is unresolvable. |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: Global config files are often synced between machines through a dotfiles repo: ~/.config/zed/settings.json (Zed's globalPath), ~/.codex/config.toml, ~/.cursor/mcp.json, ~/.gemini/settings.json. They now carry machine-specific paths such as /Users/<name>/.nvm/versions/node/v22.x.y/.... On another machine (a different home path, OS or nvm patch version), the synced entry fails to start where bare argent mcp resolved on both. Each machine's argent update rewrites the file to its own paths, and a local-mode init there offers to remove the entry as dead.
There was a problem hiding this comment.
Leaving open: pinning machine-specific paths into synced dotfiles is the trade-off this PR makes. Whether to offer an opt-out is the author's call.
| const { command, args } = entry; | ||
| if (!path.isAbsolute(command) || !/^node(\.exe)?$/i.test(path.basename(command))) return false; | ||
| if (args.length !== 2 || args[1] !== "mcp" || !path.isAbsolute(args[0])) return false; | ||
| return args[0].split("\\").join("/").includes(`node_modules/${PACKAGE_NAME}/`); |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: The writer and this check identify the global install differently:
getGloballyInstalledLaunchPathsrealpaths the bin and accepts the package by thenamein its package.json.- This line requires the literal
node_modules/@swmansion/argent/in the path.
Some global installs resolve to a path without that segment:
npm linknpm i -g <folder>- an alias such as
npm i -g argent@npm:@swmansion/argent, which lands inlib/node_modules/argent/
For these, init pins the entry and argent then treats its own entry as customized. Every update prints "left a customized entry untouched" and never re-pins it. Once the target is gone, the stale sweep only warns about a "custom command" and never offers to remove it.
What I ran:
- This head's
writeMcpConfigs({installMode:"global", scope:"global"}), withlib/node_modules/@swmansion/argentsymlinked to an outside checkout. It wrote{"command":".../prefix/bin/node","args":[".../checkout/dist/cli.js","mcp"]}, andisGlobalNodeEntryandisArgentManagedEntryboth returnedfalsefor that entry. - The alias layout, installed with the real
npm i -g --prefix: after moving to a new prefix,update --globalprinted! Cursor left a customized entry untouchedand kept the deleted v1 paths.
There was a problem hiding this comment.
Leaving open: either the writer stops pinning these layouts (dev npm link users get bare argent) or the managed check accepts any absolute dist/cli.js. The author should pick one.
| // once the install materializes. (init still writes the fallback from | ||
| // scratch, with its own warning.) | ||
| const skipEntryRewrite = localCmdMode?.kind === "local-npx"; | ||
| // Resolved after the update, so global entries point at the fresh install. |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: The refresh runs in the process that did the install, using code loaded before the install. So the first argent update (or update-argent call) from an older release onto this one runs the old installer, which has no global-node mode, and rewrites global entries as bare argent. Only a second update pins them. A #1287 user who upgrades the usual way still gets ENOENT, and nothing tells them to run update again.
What I ran: a prefix with the published 0.28.0 dist relabelled 0.27.0, and a test-double npm first on PATH that swaps in this head's build on install -g.
- First
argent update --yes:Update available: v0.27.0 -> v0.28.0, and~/.cursor/mcp.jsonstayed{"command":"argent","args":["mcp"]}. - Second run:
Already on the latest version., and only then was the entry pinned toprefix2/bin/node+.../dist/cli.js.
There was a problem hiding this comment.
Leaving open: the first update runs the old installer, and code in this release can't change that. Re-running the refresh from the new binary (or a release note) is a product call.
| const cli = path.join(packageRoot, binSub); | ||
| if (!fs.existsSync(cli)) return null; | ||
| const node = path.join( | ||
| path.dirname(binaryPath), |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: This node is preferred whenever the file exists, and nothing checks that it can run argent: no version check, no check against engines (>=20.12.0). The fallback is the one the comment at mcp-configs.ts:150-153 calls "demonstrably runs argent here". cli-record.ts records process.execPath for the same install, so cli.json and the MCP entry can now name different nodes.
The trigger is a global install in a prefix whose own node is older than the node the user runs day to day. For example: an old macOS .pkg or NodeSource node at /usr/local/bin/node or /usr/bin/node, argent installed with sudo npm i -g (sudo's secure_path picks the system npm), and daily use through nvm. On the merge-base, bare argent runs through #!/usr/bin/env node with the PATH node and works. Here the entry pins the old node and argent fails in every client, including terminal-launched ones. The crash doesn't mention the Node version.
What I ran: a sandbox prefix sys/ holding a copy of the real argent 0.28.0 and a Node 18.20.8 binary at sys/bin/node, with nvm v24 first on PATH.
- The real
writeMcpConfigs(global install, global scope, Cursor), run under v24:- This head wrote
{"command":".../sys/bin/node","args":[".../sys/lib/node_modules/@swmansion/argent/dist/cli.js","mcp"]}. - The merge-base wrote
{"command":"argent","args":["mcp"]}.
- This head wrote
- MCP
initializeon stdin through each entry, with the same PATH:- This head's entry exited 1 with
TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefinedatdist/bundled-paths.js:125(Node.js v18.20.8). - The bare entry returned
serverInfo {"name":"argent","version":"0.28.0"}.
- This head's entry exited 1 with
There was a problem hiding this comment.
Leaving open: this conflicts with the Cellar execPath thread. Preferring the sibling node avoids the Homebrew keg, but the sibling may be too old. Adding an engines version check is possible, but which node wins needs the author's call.
…t scope label - update never downgrades a pinned node + cli.js entry to bare argent, and treats that shape in a project file as a customization - a Local-scope init at $HOME pins the file it shares with the global scope - skip pnpm dlx and versioned bunx copies when resolving the global install - anchor a relative PATH hit, share the cli.js resolution with cli.json - stale sweep: name the missing file, report env-carrying dead pins, ignore the global file read back at $HOME, warn about a bare project entry that outranks a pinned global one - warn when init cannot pin and falls back to bare argent
…ect-local bins - Treat any node_modules/.bin copy on PATH as a temp runner, so npx run from a project with argent as a devDependency pins the real global install, not the project copy that disappears with node_modules. - Resolve the bin dir before joining node onto it, so an fnm multishell link pins the version dir behind it instead of a per-shell path gone at logout. - With no sibling node, use the first node on PATH by its PATH name instead of the realpathed execPath, which under Homebrew is the Cellar keg that brew upgrade removes. - Gate the pinned shape on isGlobalNodeEntry, so init never writes an entry (npm link checkout, non-node runtime) that update would treat as customized.
…ithout one A pinned <node> cli.js mcp entry starts the server in an editor that never had the shell PATH, so node and npm were unresolvable while node was running it: gather-workspace-data reported both as missing and the tool-server spawn had no node to fall back to. Append the dir of process.execPath when absent, which shadows nothing the editor's own PATH already has.
latekvo
left a comment
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: Reviewed bd331df, including the two new commits. I ran init, update, mcp and update-argent end to end against sandbox homes, with Homebrew, nvm, fnm, mise, yarn, bun and pnpm install layouts, comparing each against the merge-base, and ran mutation tests on the new guards. This review adds 7 inline findings and 8 replies on existing threads.
| // node. | ||
| export function resolveGlobalCommandMode(): McpCommandMode { | ||
| const launch = getGloballyInstalledLaunchPaths(); | ||
| const node = launch?.node ?? process.execPath; |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: Still present as of bd331df, now through nodeBeside instead of process.execPath. f36f621 picks the PATH node by name, but nodeBeside realpaths its directory first (topology.ts:128). Any PATH entry that is a symlinked dir therefore still resolves to the versioned keg. That covers:
- keg-only
node@22/node@20, whose caveat says to put/opt/homebrew/opt/node@22/binon PATH. The formula's npmrc setsprefix = HOMEBREW_PREFIX, so the global argent lands in/opt/homebrew/binwith nonodebeside it, and the fallback runs. /opt/homebrew/opt/node/binon PATH with a custom npm prefix.
The entry pins /opt/homebrew/Cellar/node@22/<exact>/bin/node. The next brew upgrade node@22 deletes that keg in its default cleanup, and from then on the entry fails in every client, terminal-launched ones included. The merge-base's bare argent keeps working there. The JSDoc at topology.ts:106-114 says the bin dir's name is kept to avoid exactly this path.
What I ran:
- The real Homebrew node (
/opt/homebrew/opt/node -> ../Cellar/node/26.10.0_1) and a custom prefix holding this head's package, nobin/node, through the head'sresolveGlobalCommandMode():PATH=<prefix>/bin:/opt/homebrew/opt/node/bin:/usr/bin:/binreturnednode: "/opt/homebrew/Cellar/node/26.10.0_1/bin/node".PATH=<prefix>/bin:/opt/homebrew/bin:/usr/bin:/binreturned/opt/homebrew/bin/node.
- An emulated keg-only layout (
Cellar/node@22/22.1.0/bin/node,opt/node@22 -> ../Cellar/node@22/22.1.0,bin/argent-> cli.js, nobin/node), using the realargent init --yes --global. The head wrotecommand: <brew>/Cellar/node@22/22.1.0/bin/nodeto Windsurf's config, and the merge-base wrote{"command":"argent","args":["mcp"]}. - Then I emulated the upgrade: a new 22.2.0 keg,
optrelinked, 22.1.0 removed. Spawning the head entry with the terminal PATH failed withspawn <brew>/Cellar/node@22/22.1.0/bin/node ENOENT. The bare entry answered MCPinitializewithserverInfo {"name":"argent","version":"0.30.1"}.
| case "global-node": | ||
| return { command: mode.node, args: [mode.cli, "mcp"] }; |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: Still present as of bd331df. bd331df fixes the node/npm: null part: gather-workspace-data now reports both. The spread to later sessions is unchanged, because the reused tool-server still has only /usr/bin:/bin:/usr/sbin:/sbin plus the appended node dir. Under Homebrew that appended dir is the versioned Cellar keg, so a brew upgrade node + cleanup while the server is alive also takes node/npm away from every session reusing it.
What I ran, with a sandbox HOME, this head's package as the global install, and a stub adb reporting one device:
- Session A through the pinned entry with
env -i PATH=/usr/bin:/bin:/usr/sbin:/sbinstarted tool-server pid 68082. - Session B, a full-PATH
argent mcpwith/opt/homebrew/binand the stub on PATH, reused pid 68082.list-devices: 0 Android devices.gather-workspace-data:{node: 26.10.0, npm: 11.19.1, yarn: null, pnpm: null, bun: null, pod: null, eas: null}, though/opt/homebrew/binhaspod,yarnandeas.ps eww 68082:PATH=/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/Cellar/node/26.10.0_1/bin.
- Control, with the full-PATH session started first: 1 Android device.
| if (wroteBareGlobal) { | ||
| // Shim-based managers (Volta, pnpm global, npm's Windows wrappers) don't | ||
| // expose a node + cli.js pair to pin. | ||
| p.log.warn( | ||
| `Could not resolve absolute node and cli.js paths for the global install; wrote ` + | ||
| `${pc.cyan("argent mcp")}, which an editor started without your shell PATH can't find.` | ||
| ); |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: No test covers this warning. Changing p.log.warn( to if (0) p.log.warn( leaves the installer suite at 626/626. For pnpm and Volta users, this warning is the only sign that init wrote a bare argent that still ENOENTs in a PATH-less editor. Without it, init prints the usual + <editor> success line again, and nothing fails.
Ran: that mutant alone in a disposable worktree at this head, PATH=/usr/bin:/bin:$PATH npx vitest run in packages/argent-installer.
| const scopeOfFile = (adapter: McpConfigAdapter, configPath: string): "local" | "global" => | ||
| installMode === "global" && configPath === adapter.globalPath() ? "global" : normalizedScope; |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: This compares unresolved path strings. effectiveRoot comes from process.cwd(), which is physically resolved, while globalPath() joins os.homedir() as given. When HOME contains a symlinked component, the two name the same file but don't compare equal:
argent init --yes --globalrun from such a$HOMEwrites bareargent mcpinto~/.cursor/mcp.jsonand~/.codex/config.toml, the global files this is meant to pin.- The twin skip in init-stale-config.ts:208 (
projectPath === globalPath) misses too. A Global-scope sweep there reads the pinned entry it just wrote as a project-scope rival and prints "a project-scope entry takes precedence over the global entry in this project; ... remove it (argent uninstall)". That is the warning PRRT_kwDORW6-h86rggqX reported as fixed, and following it removes the working entry.
What I ran: linkhome -> realhome, then cd linkhome; HOME=<...>/linkhome argent init --yes --global --no-telemetry from this head's package.
- Cursor and Codex got
"command": "argent". WithHOMEset to the real path, both were pinned to<prefix>/bin/node+ cli.js. - Head
cleanupStaleMcpConfigs({installMode:"global", scope:"global", effectiveRoot: realpath(HOME)})withHOME=linkhomeand a pinned Cursor entry returnedwarnedCount: 1with the line above. With the real path it returned 0.
| expect(fs.existsSync(windsurfGlobal)).toBe(false); | ||
| }); | ||
|
|
||
| it("judges a global-node entry dead by its files, not by PATH", async () => { |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: This name promises a PATH-independent verdict, but the test only covers one direction: globallyInstalled = true with cli.js gone, which gives dead. Nothing covers the case this rule exists for: argent not on PATH (after nvm use to a version without the global install, or the PATH-less #1287 shell) with both files present, which should stay live. If isProvablyDead is changed to missingGlobalNodeFile(entry) !== null || !globalArgentOnPath, the installer suite stays at 626/626. A local-mode init sweep from such a shell would then queue a working pinned entry for removal with the message removed a dead global entry (`null` no longer exists).
Ran, in a disposable copy of this head:
- With that mutant:
626 passed. - A probe test (
globallyInstalled = false, live pinned entry in~/.cursor/mcp.json,installMode:"local", a confirmer that says yes) passes on head. Under the mutant it fails withremovedCount: 1.
| export function resolveGlobalCommandMode(): McpCommandMode { | ||
| const launch = getGloballyInstalledLaunchPaths(); | ||
| if (!launch?.node) return { kind: "global" }; | ||
| const mode: McpCommandMode = { kind: "global-node", node: launch.node, cli: launch.cli }; | ||
| return isGlobalNodeEntry(getMcpEntry(mode)) ? mode : { kind: "global" }; |
There was a problem hiding this comment.
[Diplomat, Opus 5.5]: With two global installs, the pin can stop matching the install that update-argent updates. A common way to get there is nvm's documented nvm install 24 --reinstall-packages-from=22 && nvm alias default 24: it installs argent under v24 with plain npm, and the entries stay pinned to v22. A terminal-launched client then serves the session from v22's node + cli.js while v24 is first on its PATH. When the user accepts an update, update-argent spawns v22's cli.js, but update --global finds its target through which argent, which is v24:
- npm updates v24.
killToolServerForInstallDirstops only v24's tool-servers, so the v22 server serving the session keeps running the old version.- The refresh re-pins every entry to v24.
The tool still answers "Argent update initiated (v0.30.0 -> v0.30.1) ... The tool server will stop and restart automatically". A second consent repeats that message and does nothing. The merge-base can't get into this state, because a bare entry always serves the first install on PATH. It sorts itself out after a client restart.
What I ran: two scratch prefixes A and B with this head's package (versions relabelled 0.30.0 and 0.29.0), each with a stub npm, and a sandbox HOME.
- The entries were pinned from A.
- A real MCP session through the pinned A entry with
PATH=B/bin:/usr/bin:/bin:/usr/sbin:/sbinreturned serverInfo 0.30.0, and the tool-server bundlePath was under A (pid 10210). tools/call update-argentreturned the "initiated ... restart automatically" text, and the stub loggedB-npm install -g @swmansion/argent@0.30.1.- After 25 s, A was still 0.30.0, pid 10210 was alive, and both configs named
B/bin/node. - Base control: the bare entry served B, and the same call updated B and stopped its tool-server.
Problem
Editors started without the user's shell PATH (WebStorm's ACP runtime, Dock-launched apps) can't resolve the bare
argentcommandinitwrites, so the MCP server fails with ENOENT. Fixes #1287.Approach
Global-scope entries of a global install now use absolute
node+cli.jspaths; project configs keep bareargent.update/initrecognise and refresh the new shape, andargent mcpappends its own node's dir to a PATH that lacks one.Tests
argent init --yes --globalfrom a sandbox HOME against a fake npm prefix: Windsurf and a$HOMECursor file get the pinned entry; an MCPinitialize+tools/listthrough that entry underPATH=/usr/bin:/bin:/usr/sbin:/sbinanswers with 77 toolsargent updatere-pins to the firstnodeon PATH when the prefix has none, prefers the siblingnodewhen it is back, andinitwarns and writes bareargentwith no node on PATHwhichagainst a fake prefix, fnm-style symlinked bin dir,npm linkcheckout, projectnode_modules/.binahead on PATH), and theargent mcpPATH fix through the real dispatcherScreenshots