Skip to content

MUL-5547: fix(daemon): detect Volta-managed CLIs - #6295

Open
multica-eve wants to merge 8 commits into
mainfrom
fix/volta-shim-command-name-detection
Open

MUL-5547: fix(daemon): detect Volta-managed CLIs#6295
multica-eve wants to merge 8 commits into
mainfrom
fix/volta-shim-command-name-detection

Conversation

@multica-eve

@multica-eve multica-eve commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6183. Multica issue: MUL-5547.

Background

Volta installs a single volta-shim trampoline and symlinks every managed command to it, choosing which tool to run from the name it was invoked as. Upstream get_tool_name() takes file_name() of argv[0] (volta-core/src/run/mod.rs), and get_executor() refuses the shim under its own name (Some("volta-shim") => Err(ErrorKind::RunShimDirectly)); volta-shim's main then exits 126 for any Volta error.

resolveAgentExecutablePath canonicalized bare command names through filepath.EvalSymlinks, collapsing claude/codex/pi onto that one shim path and discarding the tool selector. Result: version detection fails and none of them register.

WRN skip registering runtime name=claude error="detect version for ~/.volta/bin/volta-shim: exit status 126"
WRN skip registering runtime name=codex  error="detect version for ~/.volta/bin/volta-shim: exit status 126"
WRN skip registering runtime name=pi     error="detect version for ~/.volta/bin/volta-shim: exit status 126"

The pinned path also feeds agent.Config.ExecutablePath, so this broke task launches too, not just registration — the fix belongs at path resolution rather than in the version probe.

Core logic

When a symlink resolves to volta-shim, ask Volta for the concrete binary (volta which <command>) and pin that.

volta is invoked by absolute path from the same directory as volta-shim (both are VoltaInstall entries) rather than via PATH, since the daemon may not have Volta's bin dir at all. Answers are cached process-wide and revalidated by existence, so a steady-state daemon does not fork a subprocess on every discovery tick and a replaced binary is re-resolved.

Why pin the concrete path instead of keeping the alias. Keeping ~/.volta/bin/claude and letting the shim dispatch looks more faithful to Volta, but it breaks the {path, version} invariant the rest of the daemon relies on. The shim resolves per working directory — volta which itself prefers a project-local bin (src/command/which.rs) — so the version verified at registration would not necessarily be the version a task executes, and the minimum-version gate could be bypassed. Nor does the daemon re-probe often enough to paper over it: the refresh loop skips convergeRuntimeRegistrations entirely when no provider is missing, and resolveAgentEntry returns the cached version whenever the pinned path still exists — which an alias always does. Pinning the concrete path puts Volta installs on exactly the same footing as Homebrew or npm-global installs, and is what the reporter's own verified MULTICA_*_PATH="$(volta which claude)" workaround does.

Fail-closed. If Volta cannot be asked, we keep the shim path: version detection fails and the provider stays unregistered rather than being launched through an ungated path. MULTICA_*_PATH remains the manual override. There is a test for this.

Scope. isVoltaShimPath accepts only volta-shim and volta-shim.exe — exact names, so neighbours like volta-shim.bak or volta-shim.wrapper cannot opt out of symlink resolution. Nothing returns unresolved parent directories, so directory canonicalization and the PATH-drift guarantee stay intact. The exception lives in the shared canonicalExecutablePath helper, which also covers the ~/.multica/hooks unshadowing branch (it canonicalizes independently) and the reresolveAgentCommand / MUL-4486 self-heal path.

Testing

server/internal/daemon/agents_probe_volta_test.go uses a faithful fixture: an argv[0]-dispatching shim that exits 126 under its own name, plus a volta that answers which. Fixture versions clear agent.MinVersions (claude ≥ 2.0.0, codex ≥ 0.100.0) so registration is actually reachable.

  • TestDetectBuiltinRuntimes_RegistersVoltaManagedCLIs — the end-result test, through the real version probe and real min-version gate, asserting claude/codex/pi all reach the registration payload.
  • TestResolveAgentExecutablePath_PinsVoltaConcreteBinary — pins the concrete binary; explicitly not the shim and not the alias.
  • TestProbeAgentCLIs_DiscoversVoltaManagedCLIs — three distinct concrete paths at the discovery entry point.
  • TestResolveAgentExecutablePath_FailsClosedWithoutVoltaResolution — no fallback to the alias.
  • TestVoltaConcreteExecutable_ReresolvesAfterBinaryReplaced — cache revalidation after Volta swaps the binary.
  • TestResolveAgentExecutablePath_VoltaAliasShadowedByHooks — hooks wrapper skipped and concrete binary still pinned.
  • TestCanonicalExecutablePath_NonVoltaSymlinkStillCanonicalized / _SymlinkedParentDirIsCanonicalized — blast-radius guards for files and parent dirs.
  • TestCanonicalExecutablePath_ExplicitShimPathStaysCanonical, TestIsVoltaShimPath — edge cases and near-miss names.

Confirmed the regression tests genuinely fail without the fix (behavior branch neutralized), with the reporter's exact error — including at registration level:

claude missing from the registration payload; skipped reasons: map[string]string{
  "claude":"version detection failed: detect version for .../volta-shim: exit status 126", ...}

Full runs, all green: go build ./..., go test ./internal/daemon/... -count=1 (daemon, execenv, repocache), go test ./pkg/agent/ -count=1, go vet ./internal/daemon/, gofmt clean on touched files, git diff --check clean.

Remaining risk

  • Verified against a real Volta install. macOS arm64, Volta 2.0.2 (the reporter's version), claude-code 2.1.220, codex 0.146.0. volta-shim --version exits 126 with Volta's "should not be called directly" error; with the fix disabled detectBuiltinRuntimes registers nothing and reports both providers as version detection failed: detect version for .../volta-shim: exit status 126, matching the reporter's log; with the fix both resolve to exactly what volta which reports and reach the registration payload. Captured as an opt-in test (TestRealVolta_*, gated on MULTICA_VERIFY_VOLTA_HOME, skipped in CI).
  • volta which is a subprocess on the discovery path. It is bounded by a 5s timeout with WaitDelay, only runs when a shim is actually detected, and is cached; failure is fail-closed.
  • The concrete path is resolved from the daemon's working directory, so it reflects Volta's default toolchain rather than any project-local override. That is the deterministic behavior the version gate requires, and it matches the documented workaround.
  • If a user sets a custom VOLTA_HOME that the daemon does not inherit, volta which resolves against the default location; that is the same environment-inheritance class the login-shell PATH fallback already handles, and it fails closed.
  • Backend-only; no schema, API, or config surface touched. Revert is a branch rollback.

…red shim

Volta installs one volta-shim trampoline in ~/.volta/bin and symlinks every
managed command to it, picking the tool to run from the name it was invoked
as. resolveAgentExecutablePath canonicalized bare command names through
filepath.EvalSymlinks, which collapsed claude/codex/pi onto that one shim
path and discarded the only input that selects the tool. The shim then exits
126, so version detection failed and none of the runtimes registered:

  detect version for ~/.volta/bin/volta-shim: exit status 126

The pinned path also feeds agent.Config.ExecutablePath, so this broke task
launches too, not just registration — the fix therefore belongs at path
resolution rather than in the version probe.

canonicalExecutablePath now keeps the caller's alias path when the symlink
target is volta-shim, and resolves every other symlink as before. Placing the
exception in that shared helper covers both call sites, including the
~/.multica/hooks unshadowing branch which canonicalizes independently. The
alias is also the upgrade-stable path: it survives `volta install` replacing
the versioned binary underneath it.

Deliberately narrow — disabling symlink resolution generally would regress
the PATH-drift pinning, the hooks recursion guard, and the MUL-4486 self-heal.

Fixes #6183

Co-authored-by: multica-agent <github@multica.ai>
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
multica-docs Ready Ready Preview Aug 3, 2026 9:48am

Request Review

Record where the argv[0] dispatch behavior is defined upstream so the
exception can be re-verified without re-deriving it:

- get_tool_name() takes file_name() of argv[0] (volta-core/src/run/mod.rs)
- get_executor() rejects the shim called by its own name (RunShimDirectly)
- volta-shim's main exits 126 on any Volta error, which is the reported symptom
- the binary is named volta-shim[.exe] (volta-layout/src/v1.rs)
- Unix shims are symlinks to it; Windows shims are .cmd scripts instead
- volta-cli/volta#579 documents the same 'resolving the symlink loses the tool
  name' hazard from Volta's own side

Comment-only; no behavior change.

Co-authored-by: multica-agent <github@multica.ai>
@multica-eve

Copy link
Copy Markdown
Collaborator Author

Validated the approach against Volta's upstream source rather than inference. Summary for reviewers:

The dispatch key really is argv[0]. volta-core/src/run/mod.rs:

/// Determine the name of the command to run by inspecting the first argument to the active process
fn get_tool_name(args: &mut ArgsOs) -> Fallible<OsString> {
    args.next()
        .and_then(|arg0| Path::new(&arg0).file_name().map(tool_name_from_file_name))
        .ok_or_else(|| ErrorKind::CouldNotDetermineTool.into())
}

Go sets argv[0] to Cmd.Path, so execing the alias path is exactly what Volta needs — this is the mechanism the fix relies on, confirmed rather than assumed.

Calling the shim by its own name is an explicit upstream error, not an accident: get_executor() has Some("volta-shim") => Err(ErrorKind::RunShimDirectly), whose message is "'volta-shim' should not be called directly. Please use the existing shims provided by Volta."

Correction to the exit-code story. RunShimDirectly maps to ExitCode::InvalidArguments, which is 3, not 126. The reported 126 comes from volta-shim's own main, which overrides the per-error code for any Volta error:

Err(Error::Volta(err)) => { report_error(...); session.exit(ExitCode::ExecutionFailure); }  // = 126

So the reporter's exit status 126 is fully accounted for. Nothing in the patch or its comments depended on the wrong mapping, but the corrected chain is now recorded in the code comment.

Volta documents this hazard from its own side. volta-cli/volta#579, filed by a Volta maintainer: "Git Bash on Windows will fully resolve symlinks before calling the executable, so shims don't work because the information about what tool was actually called is lost." Same failure class as ours, independently described — which is good evidence that "don't resolve the symlink" is the intended contract and not a workaround.

Details that check out against the implementation:

  • The binary is named volta-shim[.exe] (volta-layout/src/v1.rs, VoltaInstall.shim_executable), so the basename constant is right.
  • Unix shim creation is symlink_file(shim_executable, volta_home.shim_file(name)) — a real symlink, which is what EvalSymlinks was collapsing.
  • The extension-trim + case-insensitive compare in isVoltaShimPath mirrors Volta's own tool_name_from_file_name on Windows (lowercase, trim_end_matches(".exe")).
  • Homebrew installs resolve to .../Cellar/volta/<version>/bin/volta-shim; matching on the resolved target's basename stays correct across that layout and across Volta upgrades.

Two honest scope notes surfaced by the research (neither changes the patch):

  1. Windows shims are .cmd scripts (volta run %~n0 %*) plus a Git-Bash script, not symlinks. So this code path is effectively Unix-only; the .exe handling is defensive, not load-bearing.
  2. Volta resolves versions from the CWD's package.json. detectCLIVersion doesn't set cmd.Dir, so it probes in the daemon's CWD while tasks run in the workspace. A workspace pinning a different toolchain could therefore run a different version than the one reported. That is inherent to Volta's semantics and is an argument for preserving the alias rather than against it; I left it alone rather than widening scope.

Pushed 5851eaa — comment-only, recording these upstream references inline. Verified comment-only (no non-comment line changed in the diff); go build ./..., go vet, and the volta/shim tests still pass.

…alias

Addresses review feedback on the first pass, which kept the Volta alias path
and let the shim dispatch on argv[0].

That broke the {path, version} invariant the daemon depends on. The alias
resolves per working directory (`volta which` prefers a project-local bin), so
the version verified at registration was not necessarily the version a task
would execute, and the minimum-version gate could be bypassed. The claim that
every discovery round re-probes the version was also wrong: the refresh loop
skips convergeRuntimeRegistrations entirely when no provider is missing
(agents_refresh.go), and resolveAgentEntry returns the cached version whenever
the pinned path still exists — which an alias always does.

Now we ask Volta for the concrete binary (`volta which <cmd>`, invoked by
absolute path next to volta-shim since the daemon may not have Volta's bin dir
on PATH) and pin that. This puts Volta installs on the same footing as every
other install method and keeps the gated path identical to the launched path.
It is also exactly what the reporter's verified MULTICA_*_PATH workaround does.
Resolution failure is deliberately fail-closed: we keep the shim path, version
detection fails, and the provider stays unregistered rather than being launched
through an ungated path.

Also tightened per review:

- isVoltaShimPath now accepts only "volta-shim" / "volta-shim.exe" instead of
  trimming any extension, which had matched volta-shim.bak and .wrapper.
- No path returns unresolved parent directories any more; the concrete answer
  replaces the previous `return abs`, so directory canonicalization and the
  PATH-drift guarantee are fully intact.
- `volta which` answers are cached process-wide and revalidated by existence,
  so a steady-state daemon does not fork a subprocess on every discovery tick,
  and a replaced binary is re-resolved.

Tests: fixture versions raised above agent.MinVersions (claude 2.0.0, codex
0.100.0) so registration is actually reachable, plus a registration-level
regression test through detectBuiltinRuntimes with the real version probe and
real min-version gate asserting claude/codex/pi all reach the payload. Added
fail-closed, cache-revalidation, symlinked-parent-dir, and near-miss coverage.

Co-authored-by: multica-agent <github@multica.ai>
@multica-eve multica-eve changed the title fix(daemon): keep the command name when a CLI resolves to Volta's shared shim MUL-5547: fix(daemon): detect Volta-managed CLIs Aug 3, 2026
@multica-eve

Copy link
Copy Markdown
Collaborator Author

Thanks — all three blocking items addressed in 9e2a266. The review was right that the first approach broke an invariant, so the fix changed direction rather than getting patched around.

1. Version gate vs actual launch target (blocking). Accepted, and the diagnosis was correct on both counts. I verified the two claims:

  • agents_refresh.go — the tick does if len(missing) == 0 { backoff = 0; continue }, so convergeRuntimeRegistrations never runs in steady state. My PR description's "every discovery round re-probes the version" was simply wrong; sorry for the misdirection.
  • daemon.goif agentExecutablePresent(entry.Path) { return entry, d.agentVersion(provider) } returns the cached version, and an alias always exists, so the self-heal re-probe never fires.

Rather than mark the shim dynamic and re-gate at launch, I took the other option you named: resolve through volta which and pin the concrete binary. Reasoning — the alias approach requires a new invariant (dynamic entries re-probed in task cwd, threaded through every backend's launch path), while the concrete path restores the existing one, and puts Volta on the same footing as Homebrew/npm-global installs. It is also exactly what the reporter's verified MULTICA_*_PATH="$(volta which claude)" workaround does, which is useful empirical support given neither of us has a real Volta install.

volta is invoked by absolute path from volta-shim's own directory (both are VoltaInstall entries) since the daemon may not have Volta's bin dir on PATH. Resolution failure is fail-closed — we keep the shim path, version detection fails, provider stays unregistered — with a test asserting we never fall back to the alias.

Two consequences I want to be explicit about rather than bury: this gives up Volta's project-local selection in favour of the default toolchain, which is the deterministic behavior the gate requires; and the concrete path can be stable across package upgrades, so version staleness there is the same pre-existing property every stable path has (Homebrew, npm global) rather than a Volta-specific invariant break.

2. Exception too broad (blocking). Both sub-points fixed:

  • The return abs is gone entirely — the concrete path replaces it, so no unresolved parent directories are ever returned and directory canonicalization / PATH-drift pinning are fully intact. Added TestCanonicalExecutablePath_SymlinkedParentDirIsCanonicalized for the symlinked-parent-dir case you asked for.
  • isVoltaShimPath no longer trims arbitrary extensions. It now matches exact names only (volta-shim, volta-shim.exe), with volta-shim.bak, volta-shim.wrapper, volta-shim.exe.bak, volta-shim-wrapper and my-volta-shim as negative cases.

3. Registration not covered (blocking). Fixed, and this caught a real hole — you were right that the old fixture's 1.2.3 was below claude's 2.0.0 minimum, so a registration test would have silently proven nothing. Fixture versions now clear agent.MinVersions (claude 2.1.0, codex 0.140.0), and TestDetectBuiltinRuntimes_RegistersVoltaManagedCLIs drives the real detectBuiltinRuntimes with no detectAgentVersion / checkAgentMinVersion stubs, asserting all three providers reach the payload with the right versions. Without the fix it fails with the reporter's exact error surfaced through the skip reasons:

claude missing from the registration payload; skipped reasons: map[string]string{
  "claude":"version detection failed: detect version for .../volta-shim: exit status 126", ...}

Also added cache-revalidation coverage (volta swaps the binary → old path dropped and re-resolved), which is the closest analogue to the dynamic-version case you raised now that the path is concrete.

Process nit. Title is now MUL-5547: fix(daemon): detect Volta-managed CLIs, and I confirmed the link landed via multica issue pull-requests rather than assuming. I did not add a closing keyword — MUL-5547 is a triage issue and I did not want to auto-advance it on merge without being asked.

Verification: go build ./..., go test ./internal/daemon/... -count=1, go test ./pkg/agent/ -count=1, go vet ./internal/daemon/, gofmt, git diff --check — all green. Still no real-Volta end-to-end run; that remains the main open risk.

The fixture tests model Volta's shim behavior; this proves the model matches a
real installation, which is the one gap a fixture cannot close by construction.

Verified on macOS arm64 with Volta 2.0.2 (the reporter's version), claude-code
2.1.220 and codex 0.146.0:

- ~/.volta/bin/{claude,codex} are symlinks to a shared volta-shim, and
  `volta-shim --version` exits 126 with Volta's "should not be called directly"
  error — the reported symptom, reproduced.
- With the fix disabled, detectBuiltinRuntimes registers nothing and reports
  both providers as "version detection failed: detect version for
  .../volta-shim: exit status 126", matching the reporter's daemon log.
- With the fix, both resolve to exactly what `volta which` reports, clear the
  real min-version gate, and reach the registration payload.

Gated on MULTICA_VERIFY_VOLTA_HOME so CI and ordinary `go test` runs skip it;
the file header documents the full setup for re-running it.

Co-authored-by: multica-agent <github@multica.ai>
@multica-eve

Copy link
Copy Markdown
Collaborator Author

The standing caveat is now closed: verified against a real Volta install, not just the fixture. macOS arm64, Volta 2.0.2 (the reporter's exact version), @anthropic-ai/claude-code@2.1.220, @openai/codex@0.146.0. Installed with --skip-setup into a scoped VOLTA_HOME, so no shell profile was touched.

1. The reported setup reproduces exactly. Both commands are symlinks to one shared shim, and the shim refuses to run under its own name:

$ readlink $VOLTA_HOME/bin/claude
.../volta-verify/bin/volta-shim

$ $VOLTA_HOME/bin/claude --version
2.1.220 (Claude Code)                    # exit 0

$ $VOLTA_HOME/bin/volta-shim --version
Volta error: 'volta-shim' should not be called directly.
Please use the existing shims provided by Volta (node, yarn, etc.) to run tools.
                                         # exit 126

That confirms on real hardware what I had only derived from upstream source last turn — including that the 126 carries the RunShimDirectly message.

2. With the fix disabled, the daemon fails exactly as reported. Running the real detectBuiltinRuntimes against this install:

registered: map[string]string{}
skipped:    map[string]string{
  "claude":"version detection failed: detect version for .../volta-verify/bin/volta-shim: exit status 126",
  "codex": "version detection failed: detect version for .../volta-verify/bin/volta-shim: exit status 126"}

Nothing registers, and the skip reasons match the reporter's daemon log line for line.

3. With the fix, both register. Same install, fix enabled:

claude -> .../tools/image/packages/@anthropic-ai/claude-code/bin/claude   version "2.1.220 (Claude Code)"
codex  -> .../tools/image/packages/@openai/codex/bin/codex                version "codex-cli 0.146.0"
registered: map[string]string{"claude":"2.1.220 (Claude Code)", "codex":"codex-cli 0.146.0"}
skipped:    map[string]string(nil)

The pinned paths are byte-identical to what volta which reports, and both clear the real agent.MinVersions gate (claude ≥ 2.0.0, codex ≥ 0.100.0) — so the earlier worry about the fixture's version being below the minimum is settled on real versions too.

Two design assumptions also confirmed by the real layout rather than by reading:

  • volta, volta-shim and volta-migrate really do sit in the same directory, which is what makes the absolute-path sibling invocation of volta which safe.
  • Volta's package image path (tools/image/packages/<pkg>/bin/<cmd>) is not versioned by package version, which is why I described the concrete path as stable across upgrades and the residual version-staleness as the same property every stable path already has.

Captured as da8cc602 — an opt-in TestRealVolta_* set gated on MULTICA_VERIFY_VOLTA_HOME, so CI and ordinary go test runs skip it (verified: all three SKIP without the variable, pass with it). The file header documents the full install-and-run recipe so this is repeatable rather than a one-off claim.

go build ./... and go test ./internal/daemon/... -count=1 green; gofmt / go vet / git diff --check clean.

…nvironment

Addresses the four blocking review items on the previous pass. All were real;
each has a regression test verified to fail without its fix.

1. The GUI/login-shell fallback bypassed the new resolution. probeAgentCLIs and
   reresolveAgentCommand wrote resolveAgentsViaLoginShell results straight into
   AgentEntry.Path, and that script only canonicalizes the directory (it prints
   $c/$f), so a Volta alias survived intact. Terminal-launched daemons pinned the
   concrete binary while Desktop ones kept the dynamic alias — exactly the
   inconsistency this fix exists to remove. Both legs now run canonicalExecutable.

2. `volta which` inherited the daemon's working directory. Volta resolves a
   project-local bin before the user default (upstream src/command/which.rs), so
   a daemon started inside a JS project could pin that project's dependency as
   the machine-wide runtime. It now runs in the filesystem root, the only
   directory with no ancestor that can hold a package.json.

3. A path alone is not runnable. Volta package bins are frequently
   `#!/usr/bin/env node` scripts, and Volta supplies the Node platform bound to
   the package when it launches them (volta-core/src/run/binary.rs). Resolution
   now returns a resolvedExecutable{Path, PathDirs}; AgentEntry, the self-heal
   pair, the registration version probe and the task launch all carry it, so the
   gated version is produced in the same environment the task runs in. Confirmed
   necessary on a real install: codex 0.146.0 is a node script and exits 127
   without it, while claude 2.1.220 is a native binary and does not care.

4. Failure cost was additive. The old code held a global mutex across each 5s
   timeout and never cached failures, so one wedged Volta install cost a timeout
   per command. Resolution now runs outside the lock, failures start a cooldown,
   and a per-install budget caps total spend. Measured: three hung aliases went
   from 3.23s to 0.31s.

Also updates the opt-in real-Volta test, which previously masked item 3 by
putting Volta's bin dir (and therefore its node shim) on PATH.

Co-authored-by: multica-agent <github@multica.ai>
@multica-eve

Copy link
Copy Markdown
Collaborator Author

All four addressed in fd205b2b. Every one was real — I verified each against code and against the real Volta install before changing anything, and each fix has a regression test I confirmed fails without it.

1. GUI/login-shell fallback bypassed resolution. Correct, and the sharpest of the four: probeAgentCLIs and reresolveAgentCommand wrote resolveAgentsViaLoginShell results straight into AgentEntry.Path, and that script prints $c/$f — directory canonicalized, file name preserved — so a Volta alias arrived intact. Terminal launches pinned the concrete binary while Desktop launches kept the dynamic alias, which is precisely the inconsistency the fix was supposed to remove. Both legs now go through canonicalExecutable. Tests: TestProbeAgentCLIs_ShellFallbackResolvesVoltaAlias and TestReresolveAgentCommand_ShellFallbackResolvesVoltaAlias; reverting the call reproduces shell fallback pinned the Volta alias ".../bin/claude".

2. volta which inherited the daemon's cwd. Confirmed against upstream src/command/which.rs(Some(_), Some(bin_path)) => Some(bin_path) prefers the project bin. It now runs in the filesystem root, which I picked deliberately: Volta walks up from the cwd looking for package.json, so an empty temp dir is not sufficient — only the root has no ancestor that could hold one. TestVoltaResolve_IgnoresProjectDirectory builds a cwd-sensitive fixture, asserts the fixture really is sensitive (so the test can fail), then runs resolution with the process cwd inside the project. Without cmd.Dir it resolves .../node_modules/.bin/claude.

3. The path did not carry Volta's execution environment. This was the substantive one, and the real install settled it. The two CLIs behave differently: claude 2.1.220 ships a native Mach-O binary and runs anywhere, while codex 0.146.0 is a #!/usr/bin/env node script that exits 127 with no node on PATH. So a path alone genuinely is not runnable.

Resolution now returns resolvedExecutable{Path, PathDirs}, and the pair travels together through AgentEntry, the healedAgent path/version pair, the registration version probe (agent.DetectVersionWithPathDirs) and the task launch (agent.PrependPathDirs) — so the gated version is produced in the same environment the task runs in. PathDirs is Volta's Node platform dir, obtained through the same public volta which interface in the same deterministic directory. On the real install it resolves to tools/image/node/24.18.1/bin, which matches the platform.node recorded in tools/user/bins/codex.json.

You were also right that my earlier real-Volta test masked this by putting Volta's bin dir — and therefore its node shim — on PATH. That test no longer does, and there is now TestRealVolta_ResolvedEnvironmentIsRequired, which fails if no installed CLI needs the environment, so the check cannot silently become vacuous. The fixture models the same dependency structurally (a node that exists only in the platform dir), so it holds in CI without a real Volta.

4. Failure timeouts were additive. Confirmed: the old code held a global mutex across each 5s wait and never cached failures. Resolution now runs outside the lock, a failure starts a per-install cooldown, and a cumulative budget caps total spend. TestVoltaResolve_BoundsTotalCostWhenVoltaHangs uses a hanging volta fixture and a shortened timeout: 3.23s → 0.31s for three hung aliases, and removing the cooldown reproduces resolving 3 hung aliases took 3.231006333s (> 600ms).

Verification

  • Counterfactual for all four: each fix neutralized individually, each test fails with the specific diagnostic quoted above.
  • Real Volta (macOS arm64, 2.0.2, claude-code 2.1.220, codex 0.146.0): both resolve to the volta which answer with PathDirs populated, both clear the real min-version gate, both register. codex requires the resolved environment (without it: ... exit status 127).
  • go build ./..., go test ./internal/daemon/... -count=1, go test ./pkg/agent/ -count=1, go test ./internal/daemon/execenv/ -run ByteIdentical -count=1, go vet ./internal/daemon/ ./pkg/agent/, gofmt, git diff --check — all green.

Note on scope. Item 3 required widening the diff beyond the daemon package: detectAgentVersion gained a pathDirs argument (six test stubs updated) and pkg/agent gained DetectVersionWithPathDirs / PrependPathDirs. Both are additive — DetectVersion is unchanged for existing callers, and PathDirs is empty for every non-Volta entry, so no other install method changes behavior.

One residual I want to state rather than leave implied: PathDirs is the default toolchain's Node, not a per-package pin. If a user changes their default Node after installing a CLI, Volta itself would still use the Node bound at install time while we would use the new default. Closing that would mean reading tools/user/bins/<cmd>.json, i.e. depending on Volta's internal layout instead of its public CLI. I judged the public interface the better trade; happy to switch if you disagree.

CI failure was my own test, not the fix under test:

    --- FAIL: TestVoltaResolve_BoundsTotalCostWhenVoltaHangs
        resolving 3 hung aliases took 1.002604382s (> 600ms)

Two problems, both in the test:

1. The budget ignored cmd.WaitDelay. A hung shell leaves `sleep` grandchildren
   holding our stdout, and cmd.Output() blocks in Wait() until WaitDelay forces
   the pipes shut, so the real worst case for one call is timeout + WaitDelay,
   not timeout. WaitDelay was hardcoded at 1s while the test shrank the timeout
   to 300ms, so one legitimate call already blew a 2x-timeout budget on Linux.
   macOS reaped the child sooner, which is why it passed locally.

2. Counting invocations by having the hung script append to a file raced with
   the kill: the write became visible one call later (call 1 => 0 lines,
   call 2 => 1 line), so the counter could never be trusted.

The invariant being tested is "ask a broken install once per cooldown, not once
per command", so runVolta is now an injectable var and the test counts calls in
Go and simulates the stall with a sleep. No real subprocess, nothing to race
with, and the wall-clock check is kept as a secondary guard with the corrected
timeout + WaitDelay arithmetic. WaitDelay also becomes a var so the test can
shrink both halves of that budget.

Added TestVoltaResolve_FailureCooldownExpires for the other half of the circuit
breaker: a transient failure must be retried after the cooldown, not written off
for the daemon's lifetime.

Verified with CI's own flags (scripts/test-go.sh uses -race): the two timing
tests pass 8/8 under -race at 0.16s against a 450ms bound, the full
./internal/daemon/... and ./pkg/agent/... suites pass under -race, and removing
the cooldown still reproduces "invoked 3 times for 3 commands, want 1".

Co-authored-by: multica-agent <github@multica.ai>
Addresses the five blocking review items. All five were real; each fix has a
regression test verified to fail without it.

1. The rebuilt environment was not Volta's. It used `volta which node`, i.e. the
   *current default* Node, while Volta binds the Node a global package was
   installed against and uses exactly that (upstream binary.rs
   DefaultBinary::from_config reads bin_config.platform.node). It also omitted the
   shared NODE_PATH that upstream sets via
   `command.env("NODE_PATH", shared_module_path())`. Resolution now reads the
   bound platform from $VOLTA_HOME/tools/user/bins/<cmd>.json and carries both the
   bound Node bin dir and $VOLTA_HOME/tools/shared, so switching the default Node
   no longer changes which Node an installed CLI runs under.

2. The context did not reach every consumer that launches a CLI. Model discovery
   and the service-tier / thinking validators still passed only a path, so a Volta
   package script would fail there (Codex silently degrading to its static
   catalog). Introduced agent.ExecEnv and threaded it through ListModelsWithEnv,
   ValidateServiceTierWithEnv and ValidateThinkingLevelWithEnv down to every exec
   site, and folded it into the discovery cache key so one binary under two
   toolchains cannot share a cached catalog. The existing zero-env entry points
   are kept, so no other caller changes.

3. A transient Node lookup failure cached a permanently incomplete result: the
   bool from the node query was ignored, "done" was recorded anyway, and validity
   only checked the tool path. Resolution now fails closed unless BOTH halves
   succeed, and cache validity covers the environment's directories, so a removed
   Node image invalidates the entry.

4. The breaker latched open forever. `spent` accumulated for the process lifetime
   and was never reset, so two near-timeout failures exhausted the 8s budget and
   no later attempt was made even after every cooldown expired. The budget now
   bounds one breaker window and resets with the cooldown. Concurrent callers are
   merged through a singleflight group, so releasing the lock for the subprocess
   can no longer let several requests past the budget check at once.

5. A GUI daemon with a custom VOLTA_HOME resolved against the wrong install.
   `volta` inherited the daemon environment, and Volta reads package data relative
   to VOLTA_HOME (defaulting to ~/.volta), so an uninherited or wrong value
   described a different installation than the alias found. VOLTA_HOME is now
   derived from the alias path itself — shims live in $VOLTA_HOME/bin — by walking
   the symlink chain to the hop that points at the shim, and passed explicitly to
   every `volta` invocation. The opt-in real-Volta test no longer exports
   VOLTA_HOME, so it exercises that scenario instead of bypassing it.

Verified with CI's flags (scripts/test-go.sh --race) plus the real Volta install:
claude 2.1.220 and codex 0.146.0 both resolve with the bound Node and register
with VOLTA_HOME absent from the environment.

Co-authored-by: multica-agent <github@multica.ai>
@multica-eve

Copy link
Copy Markdown
Collaborator Author

All five addressed in 63e79cb4. Every one was real — I verified each against upstream source and the real Volta install before changing anything, and each fix has a regression test I confirmed fails without it.

1. The rebuilt environment was not Volta's. Confirmed from volta-core/src/run/binary.rs: DefaultBinary::from_config reads bin_config.platform.node — the Node the package was installed against — and the command carries command.env("NODE_PATH", shared_module_path()), which prefixes volta_home().shared_lib_root(). volta which node reports the current default, so it was the wrong input, and NODE_PATH was missing entirely.

Resolution now reads the bound platform from $VOLTA_HOME/tools/user/bins/<cmd>.json and carries both the bound Node bin dir and $VOLTA_HOME/tools/shared. The fixture makes this falsifiable: the bound Node's node works while the "current default" one is a decoy that exits 1, so a resolution using the default cannot pass unnoticed. Reverting to volta which node reproduces PATH dirs = [.../node/24.18.1/bin], want the install-time bound Node ".../node/20.11.0/bin".

2. The context did not reach every consumer. Added agent.ExecEnv and threaded it through ListModelsWithEnv, ValidateServiceTierWithEnv and ValidateThinkingLevelWithEnv down to every exec site in the discovery tree, and folded it into the discovery cache key. The existing zero-env entry points remain, so no other caller changes and the ~38 existing call sites are untouched. Tests cover the cache key distinguishing two toolchains, and discoverPiModels finding models under the env while finding none without it.

3. A partial answer was cached permanently. Correct on all three counts — the node query's bool was ignored, "done" was set anyway, and validity checked only the tool path. Resolution now fails closed unless both halves succeed, and voltaResolutionUsable validates the environment's directories, so a pruned Node image invalidates the entry. Ignoring the failure again reproduces a cached result with PATH:[].

4. The breaker latched open forever. Also correct, and this was the worst of the five since it was unrecoverable. spent accumulated for the process lifetime, so two near-timeout failures exhausted the budget and no attempt was made again regardless of cooldowns. The budget now bounds one window and resets with the cooldown, and concurrent callers are merged through a singleflight group so releasing the lock cannot let several past the budget check. TestVoltaResolve_BudgetResetsAfterCooldown reproduces your exact finding without the reset: invoked 2 times across 3 expired cooldowns, want 3. There is also a coalescing test asserting 8 concurrent resolutions cause one invocation.

5. Custom VOLTA_HOME resolved against the wrong install. VOLTA_HOME is now derived from the alias path rather than the environment: shims live in $VOLTA_HOME/bin, so the parent of the alias's directory is the home. It walks the symlink chain one hop at a time to the link that points at the shim, because only that link is guaranteed to sit in the shim dir — a fully-resolved path would give the install tree, which for a Homebrew install is a different tree entirely. The derived value is then passed explicitly to every volta invocation.

You were right that the real-Volta test bypassed this by exporting VOLTA_HOME. It no longer does, and I found a second bug while fixing it: t.Setenv("VOLTA_HOME", "") leaves the variable present but empty, which makes a real Volta treat its home as relative and write layout.v4 / volta.lock into the working directory. It now genuinely unsets.

Verification

  • Counterfactuals: each fix neutralized individually; each test fails with the diagnostic quoted above.
  • Real Volta (macOS arm64, 2.0.2, claude-code 2.1.220, codex 0.146.0) with VOLTA_HOME absent from the environment: both resolve to the volta which answer with the bound Node dir, both clear the real min-version gate, both register. codex still requires the environment (exit status 127 without it).
  • CI's own command: bash scripts/test-go.sh --race clean, plus go test -race ./internal/daemon/..., go test -race -p 2 -parallel 2 ./pkg/agent/..., go vet, gofmt, git diff --check.

Scope note. Item 2 widened the diff into pkg/agent's discovery surface (signatures gained a trailing ExecEnv; new execenv.go). It is additive — every previous entry point still exists with zero-env behavior, and ExecEnv's zero value is a no-op, so non-Volta installs are byte-for-byte unchanged.

One residual, stated rather than hidden: the bound platform comes from Volta's on-disk bin config, which is internal layout rather than a public CLI contract. There is no public accessor for it (volta list only prints it as prose), and item 1 cannot be satisfied without it, so I took the JSON with graceful degradation — an unreadable or unparsable config fails closed rather than guessing.

Addresses the six blocking review items. All were real; each fix has a regression
test verified to fail without it.

1. The WithEnv chain was broken in three places and discovery was under-keyed.
   thinking.go's validators still called the env-less ListModels; ACP discovery
   built an env-aware command and then assigned os.Environ()+extraEnv over
   cmd.Env, discarding it; and most dynamic discovery cached on the provider name
   alone, so two CLI paths or two Volta homes reused each other's catalog inside
   the TTL. The validators now use ListModelsWithEnv, ACP layers extraEnv on top
   of the resolved environment (extracted as acpChildEnv so it is testable), and
   all 15 cache sites key on provider + executable path + ExecEnv.

2. Volta state was not isolated per home. It keyed on the volta binary, but a
   Homebrew install has several VOLTA_HOMEs sharing one volta/volta-shim pair, so
   home B served home A's tool paths and environment. State is now keyed by
   canonical home + binary.

3. A cached entry never self-healed its environment. resolveAgentEntry returned an
   entry as long as the CLI file existed, so a Volta upgrade that prunes the old
   Node image left every task launching with a PATH pointing nowhere. Validity now
   covers the whole resolution (agentEntryLaunchable), so a stale entry is
   re-resolved and atomically replaced by the existing self-heal.

4. The breaker could still latch permanently — this time via successes. Budget
   accounting charged successful query time too, while success cleared failedAt,
   so two slow-but-working resolutions exhausted the budget and no cooldown could
   ever reset it. The budget now counts only failures and a success clears it. The
   gate is also installation-level: concurrent resolutions of different commands
   serialize behind one per-install mutex instead of each passing the check.

5. Only Node was restored. Volta's Image::bins() prefixes npm, pnpm and yarn image
   bins before Node ("so that any custom version of npm will be earlier in the
   PATH"), so an agent bound to a custom package manager could not find it. The
   bound platform is now reconstructed in full, in Volta's order.

6. A custom NODE_PATH wiped Volta's required prefix. The resolved environment was
   applied before custom_env, and NODE_PATH is not on the blocked-key list, so a
   user value replaced the shared-modules prefix outright. Layering is now
   custom_env first, resolved environment last (extracted as layerTaskEnvironment);
   because ExecEnv prefixes, the user's value is preserved after Volta's entries.

Verified with CI's own command (scripts/test-go.sh --race) and against the real
Volta install: claude 2.1.220 and codex 0.146.0 still resolve with the bound
platform and register with VOLTA_HOME absent from the environment.

Co-authored-by: multica-agent <github@multica.ai>
@multica-eve

Copy link
Copy Markdown
Collaborator Author

All six addressed in 35b31ab1. Every one was real, and each fix has a regression test I confirmed fails without it — including the two you reproduced yourself.

1. Broken WithEnv chain + under-keyed discovery cache. All three parts confirmed:

  • thinking.go:645,703 still called the env-less ListModels, so both validators dropped the toolchain.
  • ACP discovery built an env-aware command and then did cmd.Env = append(os.Environ(), p.extraEnv...), throwing the environment away entirely.
  • 14 of 15 cachedDiscovery calls keyed on the bare provider name.

Validators now use ListModelsWithEnv; the ACP env construction is extracted as acpChildEnv (env first, extraEnv layered on top) so it is unit-testable; all 15 cache sites key on provider + path + ExecEnv. Reverting thinking.go reproduces both a missing env-aware key and a cached key lacking the fingerprint; reverting the ACP line reproduces PATH = "/usr/bin".

2. State not isolated per home. Right, and the Homebrew shape is the exact trigger: several homes, one volta/volta-shim pair. State is now keyed by canonical home + binary. The test builds two fixtures sharing one install dir; keying on the binary alone reproduces home B reused home A's tool path.

3. Cached environment never self-healed. Confirmed — resolveAgentEntry returned the entry whenever the CLI file existed, so pruning the bound Node image left tasks launching with a PATH pointing nowhere, permanently. Validity now covers the whole resolution via agentEntryLaunchable, so the existing self-heal re-resolves and atomically replaces. Two tests: the predicate itself, and resolveAgentEntry re-resolving when only the toolchain is pruned (the CLI deliberately survives, so the test proves the environment is what invalidates).

4. Breaker still latchable — via successes. This was the sharpest catch: successful query time was charged to the budget while success cleared failedAt, so two slow-but-working resolutions exhausted it and no cooldown could ever reset it. The budget now counts only failures and a success clears it. The gate is also installation-level: concurrent resolutions of different commands serialize behind one per-install mutex and re-check the budget after acquiring it, so they can no longer each pay a timeout. Tests cover both — charging successes reproduces pi (request 3) was refused, and the concurrency test asserts one invocation for three concurrent commands against a broken install.

5. Only Node restored. Confirmed against Image::bins(), which pushes npm, pnpm, yarn and Node last — deliberately, "so that any custom version of npm will be earlier in the PATH". The bound platform is now reconstructed in full in that order, with a test pinning the ordering and a second one keeping the common node-only case simple. A pinned manager whose image is missing is skipped rather than failing the resolution: an absent PATH entry cannot affect execution, and skipping keeps cache validity honest about what must exist.

6. Custom NODE_PATH wiped the prefix. Confirmed, and worth noting why it only bites NODE_PATH: PATH is on isBlockedEnvKey, NODE_PATH is not. Layering is now custom_env first, resolved environment last, extracted as layerTaskEnvironment so the ordering is testable rather than implicit in a 300-line function. Because ExecEnv prefixes, the user's value survives after Volta's entries instead of replacing them. Swapping the order reproduces NODE_PATH = "/user/modules" against the expected "/volta/tools/shared:/user/modules".

Verification

  • Counterfactuals for all six, each failing with the diagnostic quoted above.
  • CI's own command: bash scripts/test-go.sh --race clean, plus go test -race ./internal/daemon/..., go vet, gofmt, git diff --check.
  • Real Volta (2.0.2, claude-code 2.1.220, codex 0.146.0) with VOLTA_HOME absent: both resolve with the bound platform, clear the real min-version gate, register; codex still needs the environment (exit status 127 without it).

On your ./pkg/agent/... Codex cleanup/timeout flake: I ran that package under CI's exact flags (-race -p 2 -parallel 2) and the full wrapper, both clean here, so I could not reproduce it. Nothing in this change touches Codex process cleanup — the only pkg/agent edits are the env plumbing — so I agree it looks unrelated, and the backend job is the place to confirm.

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.

[Bug]: bug(daemon): Volta-managed CLIs are not detected because symlink resolution loses the command name

1 participant