Skip to content

CLIs installed via nvm/Homebrew are not found when Codeman runs as a service - #329

Merged
Ark0N merged 1 commit into
Ark0N:masterfrom
aakhter:cli-login-shell-resolution
Aug 21, 2026
Merged

CLIs installed via nvm/Homebrew are not found when Codeman runs as a service#329
Ark0N merged 1 commit into
Ark0N:masterfrom
aakhter:cli-login-shell-resolution

Conversation

@aakhter

Copy link
Copy Markdown
Contributor

The bug

A CLI installed by nvm, Homebrew, or a user-level npm prefix lives on a PATH that only a login shell sets up.

Codeman running under systemd or launchd never gets that PATH — launchd hands a job /usr/bin:/bin:/usr/sbin:/sbin. So every resolver reports the CLI as unavailable on installs where it is plainly present and works fine from a terminal.

This is the same PATH problem service install already works around by baking the installing shell's PATH into the unit; the resolvers had no equivalent.

The fix

Each of the six resolvers carried its own hand-rolled copy of the same PATH walk, so rather than patch the same bug six times this factors them onto one shared createCliExecutableResolver() with an explicit lookup order:

  1. the server process PATH
  2. common install directories, in order
  3. an interactive login shell — last resort

Only step 3 spawns anything, and only once the cheap lookups have missed. Behaviour is unchanged wherever the CLI was already on the process PATH, since that stays the first thing checked.

Success is cached and failure is retried, so installing a CLI while the server is running is picked up without a restart.

Also included

formatCliNotFoundMessage() — a failure now explains where it looked instead of just asserting the CLI is missing. The diagnostics are bounded in length and control characters are flattened, so a not-found message cannot dump arbitrary environment data into a response.

Size

Net −103 lines across the six resolvers, plus the shared module. shell-resolver.ts is used as-is and unchanged.

Tests

test/cli-executable-resolver.test.ts, 20 cases: the precedence order, login-shell-only resolution, the cache-success/retry-failure rule, unsafe-name rejection, and the bounded diagnostics.

Verified non-vacuous by disabling the login-shell probe — 2 tests fail, including the one that pins resolution through the injected login-shell runner.

Full gate green: tsc --noEmit, eslint, prettier, and vitest run --config config/vitest.ci.config.ts (5394 passed / 12 skipped).

… as a service
A CLI installed by nvm, Homebrew or a user-level npm prefix lives on a PATH that
only a login shell sets up. Codeman running under systemd or launchd does not get
that PATH — launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` — so every
resolver reported the CLI as unavailable on installs where it is plainly there
and works from a terminal.
Each of the six resolvers had its own hand-rolled copy of the same PATH walk, so
the fix is factored into one shared `createCliExecutableResolver()` with an
explicit lookup order: the server process PATH, then common install directories in
order, then an interactive login shell as the last resort. Only the last step
spawns anything, and only when the cheap lookups have already missed.
Also adds `formatCliNotFoundMessage()`, so a failure explains where it looked
instead of just asserting the CLI is missing. Its diagnostics are bounded and
control characters are flattened, so a not-found message cannot dump arbitrary
environment data.
Success is cached and failure is retried, so installing a CLI while the server is
running is picked up without a restart.
Net -103 lines across the six resolvers. Behaviour is unchanged wherever the CLI
was already on the process PATH: that remains the first thing checked.
Tests: 20 cases in test/cli-executable-resolver.test.ts covering the precedence
order, login-shell-only resolution, the caching rule, unsafe-name rejection, and
the bounded diagnostics.
@Ark0N
Ark0N merged commit f9a8493 into Ark0N:masterAug 21, 2026
2 checks passed
@Ark0N

Copy link
Copy Markdown
Owner

Merged, thank you! The login-shell fallback is exactly what service installs on nvm/Homebrew setups needed. Follow-ups landing on master before release: negative-result caching so a missing CLI doesn't re-run the login shell on every request, SIGKILL on the exec timeout (interactive bash shrugs off SIGTERM), and the VITEST guards in the pi resolver restored so tests never execute a real pi binary from PATH. Ships in 1.20.0.

Ark0N pushed a commit that referenced this pull request Aug 21, 2026
…red VITEST hermeticity, wired not-found diagnostics
Post-merge follow-ups for PR #329 (shared CLI executable resolution):
- Negative-cache resolution misses with a doubling backoff (1min -> 5min
cap, cliResolveRetryDelayMs, mirroring claudeVersionRetryDelayMs): the
shared resolver cached success only, so a missing CLI re-ran the whole
chain - ending in a synchronous interactive login-shell spawn bounded by
the 5s EXEC_TIMEOUT_MS - on every /api/<cli>/status request and Run
attempt, stalling the event loop each time, forever. Success still caches
for the process lifetime, so an installed CLI is picked up within minutes
without a restart. Tests drive the backoff via an injectable clock
(createCliExecutableResolver `now` option, threaded through the
createPiResolverForTest / createAntigravityResolverForTest wrappers).
- Pass killSignal: 'SIGKILL' on the resolver's login-shell spawn and on the
pi/claude --version probes: execFileSync's timeout only SENDS the kill
signal and then keeps waiting for the child to exit, and interactive bash
ignores SIGTERM, so a login shell stuck in a blocking .bash_profile
survived the timeout and blocked the server permanently.
- Restore test hermeticity (PR #329 deleted pi's VITEST guards, and one
test pinned the deletion): under vitest the production resolver host now
replaces un-injected IO primitives with inert stubs - no real PATH
scanning, no login-shell spawns - and probePiVersion never executes a
`pi` candidate again (`pi` is a generic binary name, so route tests
hitting /api/pi/status executed whatever binary the machine carried).
Tests opt in through the runCommand/isExecutableFile injection hooks or
allowRealIoUnderVitest for real-filesystem fixtures. The deletion-pinning
test is replaced by behavioral pins, including a real-executable fixture
in the new test/pi-cli-resolver.test.ts that fails loudly if the pi gate
is ever removed again.
- Wire the six get*NotFoundMessage() exports (previously dead) into their
intended call sites: the createSession throws in tmux-manager and the
availability gates on POST /api/sessions and POST /api/quick-start in
session-routes, replacing a third hardcoded copy of the text. A not-found
error now names where resolution looked (server PATH, login shell,
checked directories). npm run knip no longer reports any unused export
from the resolver modules.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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.

2 participants

@aakhter@Ark0N