Skip to content

fix(installer): pin absolute node + cli.js in global MCP entries - #1344

Draft
blazejkustra wants to merge 8 commits into
mainfrom
fix/mcp-absolute-global-command
Draft

blazejkustra wants to merge 8 commits into
mainfrom
fix/mcp-absolute-global-command

Conversation

@blazejkustra

@blazejkustra blazejkustra commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Editors started without the user's shell PATH (WebStorm's ACP runtime, Dock-launched apps) can't resolve the bare argent command init writes, so the MCP server fails with ENOENT. Fixes #1287.

Approach

Global-scope entries of a global install now use absolute node + cli.js paths; project configs keep bare argent. update/init recognise and refresh the new shape, and argent mcp appends its own node's dir to a PATH that lacks one.

Tests

  • Real argent init --yes --global from a sandbox HOME against a fake npm prefix: Windsurf and a $HOME Cursor file get the pinned entry; an MCP initialize + tools/list through that entry under PATH=/usr/bin:/bin:/usr/sbin:/sbin answers with 77 tools
  • argent update re-pins to the first node on PATH when the prefix has none, prefers the sibling node when it is back, and init warns and writes bare argent with no node on PATH
  • Unit tests: entry shape, managed-entry detection, dead/live sweep, node choice (real which against a fake prefix, fnm-style symlinked bin dir, npm link checkout, project node_modules/.bin ahead on PATH), and the argent mcp PATH fix through the real dispatcher
  • Installer suite (626), argent suite (94), eslint, prettier and docs build pass
  • Not verified: inside WebStorm itself, Windows, Volta / pnpm shims (documented as the bare fallback)

Screenshots

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>
@coderabbitai

coderabbitai Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: Findings are inline. A related pre-existing problem outside this diff, argent uninstall deciding global-scope cleanup by a PATH probe, is filed as #1391.

Comment thread packages/argent-installer/src/update.ts
// node.
export function resolveGlobalCommandMode(): McpCommandMode {
const launch = getGloballyInstalledLaunchPaths();
const node = launch?.node ?? process.execPath;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 getGloballyInstalledLaunchPaths JSDoc (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".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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/bin on PATH. The formula's npmrc sets prefix = HOMEBREW_PREFIX, so the global argent lands in /opt/homebrew/bin with no node beside it, and the fallback runs.
  • /opt/homebrew/opt/node/bin on 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, no bin/node, through the head's resolveGlobalCommandMode():
    • PATH=<prefix>/bin:/opt/homebrew/opt/node/bin:/usr/bin:/bin returned node: "/opt/homebrew/Cellar/node/26.10.0_1/bin/node".
    • PATH=<prefix>/bin:/opt/homebrew/bin:/usr/bin:/bin returned /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, no bin/node), using the real argent init --yes --global. The head wrote command: <brew>/Cellar/node@22/22.1.0/bin/node to Windsurf's config, and the merge-base wrote {"command":"argent","args":["mcp"]}.
  • Then I emulated the upgrade: a new 22.2.0 keg, opt relinked, 22.1.0 removed. Spawning the head entry with the terminal PATH failed with spawn <brew>/Cellar/node@22/22.1.0/bin/node ENOENT. The bare entry answered MCP initialize with serverInfo {"name":"argent","version":"0.30.1"}.

Comment on lines +103 to +107
const node = path.join(
path.dirname(binaryPath),
process.platform === "win32" ? "node.exe" : "node"
);
return { cli, node: fs.existsSync(node) ? node : null };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 argent keeps 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 ran init. A later fnm use in that shell, or --use-on-cd entering 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +96 to +98
const binaryPath = getGlobalBinaryPath();
const packageRoot = packageRootOfBinary(binaryPath);
if (!binaryPath || !packageRoot) return null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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/.bin and every ancestor's node_modules/.bin ahead of the user's PATH, and TEMP_RUNNER_MARKERS doesn't filter them. A project-local copy wins over the real global prefix, and so does a hoisted monorepo copy or a stray ~/node_modules copy. The documented npx @swmansion/argent@latest init with 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 that node_modules goes away. update run from a dir with a stray ancestor copy also rewrites an already-correct pinned entry to it.
  • bunx with a version spec (bunx @swmansion/argent@latest init). bun installs into $TMPDIR/bunx-<uid>-<spec>/node_modules and puts that .bin (a real symlink to cli.js) first. The bun/install/cache marker 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/argent devDependency copy, under real npx (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 moving proj/node_modules away, the pinned entry failed with Error: 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"]}.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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's dlx-<pid> dirs, so it misses this path.
  • That .bin/argent is a /bin/sh shim. packageRootOfBinary therefore climbs to the dlx dir's package.json (named dlx) 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 bare argent into global configs, and an init or update run 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.cmd wrappers 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1943480. Partial: init now warns when it can't pin and writes bare argent, and the docs (fa9ac19) say so and name Volta and pnpm. Detecting and resolving shims (Volta, pnpm, Windows .cmd) is not attempted, so leaving this open.

Comment thread packages/argent-installer/src/init-stale-config.ts Outdated
Comment thread packages/argent-installer/src/topology.ts Outdated
Comment thread packages/argent-installer/src/mcp-configs.ts
Comment on lines +109 to +112
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/argent-installer/src/mcp-configs.ts Outdated

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: New findings are inline. Earlier threads with new evidence got replies. Two related problems outside this diff are filed as #1457 (Homebrew-cask adb missing from the Android binary fallbacks) and #1458 (a Local-scope init drops Windsurf's alwaysAllow).

Comment thread packages/argent-installer/src/mcp-configs.ts
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}/`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: The writer and this check identify the global install differently:

  • getGloballyInstalledLaunchPaths realpaths the bin and accepts the package by the name in 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 link
  • npm i -g <folder>
  • an alias such as npm i -g argent@npm:@swmansion/argent, which lands in lib/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"}), with lib/node_modules/@swmansion/argent symlinked to an outside checkout. It wrote {"command":".../prefix/bin/node","args":[".../checkout/dist/cli.js","mcp"]}, and isGlobalNodeEntry and isArgentManagedEntry both returned false for that entry.
  • The alias layout, installed with the real npm i -g --prefix: after moving to a new prefix, update --global printed ! Cursor left a customized entry untouched and kept the deleted v1 paths.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/argent-installer/src/init-stale-config.ts Outdated
Comment thread packages/argent-installer/src/init-stale-config.ts
Comment thread packages/argent-installer/test/global-command-mode.test.ts Outdated
Comment thread packages/docs/docs/fundamentals/mcp-server.mdx Outdated
Comment thread packages/argent-installer/src/topology.ts
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.json stayed {"command":"argent","args":["mcp"]}.
  • Second run: Already on the latest version., and only then was the entry pinned to prefix2/bin/node + .../dist/cli.js.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat, Opus 5.5]: One more inline finding from a final pass.

const cli = path.join(packageRoot, binSub);
if (!fs.existsSync(cli)) return null;
const node = path.join(
path.dirname(binaryPath),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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"]}.
  • MCP initialize on 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 undefined at dist/bundled-paths.js:125 (Node.js v18.20.8).
    • The bare entry returned serverInfo {"name":"argent","version":"0.28.0"}.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

blazejkustra and others added 7 commits October 21, 2026 23:58
…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.
@blazejkustra
blazejkustra requested a review from latekvo October 23, 2026 15:53

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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/bin on PATH. The formula's npmrc sets prefix = HOMEBREW_PREFIX, so the global argent lands in /opt/homebrew/bin with no node beside it, and the fallback runs.
  • /opt/homebrew/opt/node/bin on 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, no bin/node, through the head's resolveGlobalCommandMode():
    • PATH=<prefix>/bin:/opt/homebrew/opt/node/bin:/usr/bin:/bin returned node: "/opt/homebrew/Cellar/node/26.10.0_1/bin/node".
    • PATH=<prefix>/bin:/opt/homebrew/bin:/usr/bin:/bin returned /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, no bin/node), using the real argent init --yes --global. The head wrote command: <brew>/Cellar/node@22/22.1.0/bin/node to Windsurf's config, and the merge-base wrote {"command":"argent","args":["mcp"]}.
  • Then I emulated the upgrade: a new 22.2.0 keg, opt relinked, 22.1.0 removed. Spawning the head entry with the terminal PATH failed with spawn <brew>/Cellar/node@22/22.1.0/bin/node ENOENT. The bare entry answered MCP initialize with serverInfo {"name":"argent","version":"0.30.1"}.

Comment thread packages/argent-installer/src/mcp-configs.ts
Comment thread packages/argent-installer/src/update.ts
Comment on lines +132 to +133
case "global-node":
return { command: mode.node, args: [mode.cli, "mcp"] };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:/sbin started tool-server pid 68082.
  • Session B, a full-PATH argent mcp with /opt/homebrew/bin and 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/bin has pod, yarn and eas.
    • 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.

Comment thread packages/argent-installer/src/init-mcp-write.ts
Comment on lines +166 to +172
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.`
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread packages/argent-installer/src/mcp-configs.ts
Comment on lines +83 to +84
const scopeOfFile = (adapter: McpConfigAdapter, configPath: string): "local" | "global" =>
installMode === "global" && configPath === adapter.globalPath() ? "global" : normalizedScope;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 --global run from such a $HOME writes bare argent mcp into ~/.cursor/mcp.json and ~/.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". With HOME set to the real path, both were pinned to <prefix>/bin/node + cli.js.
  • Head cleanupStaleMcpConfigs({installMode:"global", scope:"global", effectiveRoot: realpath(HOME)}) with HOME=linkhome and a pinned Cursor entry returned warnedCount: 1 with 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 () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 with removedCount: 1.

Comment on lines +154 to +158
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" };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.
  • killToolServerForInstallDir stops 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:/sbin returned serverInfo 0.30.0, and the tool-server bundlePath was under A (pid 10210).
  • tools/call update-argent returned the "initiated ... restart automatically" text, and the stub logged B-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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Problem with argent when running from webstorm

2 participants