Commit Graph
4 Commits
Author SHA1 Message Date
Neil 901c6771ff fix(agents): detect agent CLIs installed outside a version manager (#18336)
* fix(agents): detect agent CLIs installed outside a version manager

The install-dir fallback that answers "is this agent installed?" when the
login-shell PATH probe fails listed only version-manager bin dirs, so
codex/opencode/cursor-agent installed by Homebrew, npm's default global
prefix, snap, nix, or the CLI's own installer read as not installed.

Where this is decisive, corrected from the previous message: the `orca` CLI,
whose detectSkillsCliAgentKeys (src/cli/handlers/skills.ts) calls
detectCommandsInInstallDirs directly and whose entry point seeds no PATH, plus
unpackaged/dev runs. NOT the packaged desktop app: patchPackagedProcessPath
(configure-process.ts:114) appends /opt/homebrew/bin, /usr/local/bin,
~/.opencode/bin and the Linux/nix prefixes onto process.env.PATH at
main-process-preflight.ts:147, before any detection, and mergePathSegments
preserves them, so the PATH scan reaches those dirs first and this fallback
never fires for them. That means this does NOT explain the packaged macOS
v1.4.194 report of codex/cursor-agent/opencode all undetected -- that report
stays open and uninvestigated.

Second correction: the fallback now carries the prefixes Homebrew actually
uses on Linux (/home/linuxbrew/.linuxbrew/bin), plus /snap/bin and the two nix
profile dirs, matching what patchPackagedProcessPath already seeds. The WSL
guest prelude gains the same entries. Leaving them out closed the native/WSL
asymmetry on darwin only, on the platforms where the fallback is decisive.

Appended last so a version-manager install still wins, and kept out of
getVersionManagerBinPaths, whose result is PREPENDED to PATH (#18234). Lives in
its own module so node-cli-command-resolution.ts stays under max-lines.

The test stages every path through `join` and asserts via
detectCommandsInInstallDirs as well as resolveCliCommands, so it holds on a
Windows dev machine and pins the "absolute path means installed" contract.

* fix(agents): align system install-dir order across the three PATH lists

Round-2 review remediation. The blocking finding was about the handoff
artifact, not the code: the summary handed to review described a 2-file/+38
change with 3 new macOS dirs, while HEAD is 4 files/+227 with 6 lookup dirs
plus 5 new WSL-guest prelude entries, and the quoted failing test names never
existed. Restated against HEAD in the handoff; no rebuttal, the reviewer was
right.

Justification for the entries the summary never described: the fallback exists
to close the native/WSL asymmetry for a CLI no version manager installed, and
patchPackagedProcessPath already seeds Linuxbrew, /snap/bin and both nix
profile dirs (configure-process.ts:141-158). Shipping only the darwin subset
would have left a Linux or WSL user with a snap/nix install still reading as
not installed while the packaged macOS user did not -- the asymmetry the change
is for.

Code changes, all from the non-blocking list:

- The three lists disagreed on order while claiming to be kept in step, so a
  CLI in both /usr/local/bin and /snap/bin could resolve to a different binary
  than the seeded PATH scan or the WSL guest probe found. All three now use the
  seed's relative order, pinned by a new duplicate-install test and an offset
  assertion on the prelude. The prelude's system block also moved after the nvm
  glob so a version manager still wins in the guest, as it does natively.
- The parity docstring asserted "the same set patchPackagedProcessPath appends,
  minus the sbin dirs and the generic ~/bin", which was false: it also omits
  ~/.vite-plus/bin (seeded by configure-process.ts:156, but no probed agent
  command maps to it) and /opt/homebrew off darwin. All three gaps are now
  named as deliberate.
- win32 returns [] and stays that way, but the branch now says why:
  %USERPROFILE%\.opencode\bin has never had install-dir coverage in either
  list, and the seed's system block is POSIX-only too. Pre-existing, unchanged.
- The detectCommandsInInstallDirs case read the ambient process.env.PATH, so on
  a box with /usr/local/bin on PATH only the opencode assertion exercised the
  fallback. It now stubs the GUI-launch PATH, so both do.

Unchanged and restated: this does NOT explain the packaged macOS v1.4.194
report of codex/cursor-agent/opencode all undetected, and must not close G6
report #3. patchPackagedProcessPath returns early unless app.isPackaged and
seeds all six dirs before any detection, and mergePathSegments never deletes
them, so the packaged PATH scan reaches them first and this fallback never
fires there. Decisive only for the `orca` CLI's detectSkillsCliAgentKeys and
unpackaged/dev runs.

Verification: the suite is red without the production hunks (5 failed/3 passed)
and green with them (8 passed); 11 related suites pass (197 tests); tc:node,
tc:cli, oxlint, oxfmt and the max-lines ratchet are clean.

* fix(agents): correct the ordering-parity claims and widen the seed-leak guard

Round-3 review found two docstring claims that are false as written and one
guard that only asserted a third of its list.

- system-cli-install-dirs.ts claimed a CLI in two of these dirs resolves the
  same here as in the packaged PATH seed. True inside the system block, false
  across it: `claude` in both ~/.local/bin and /opt/homebrew/bin resolves to
  ~/.local/bin via the fallback (getBaseVersionManagerDirectories leads) and to
  /opt/homebrew/bin via the seed, which appends ~/.local/bin last. Scope the
  claim to the block and name the gap instead of asserting it away. No behavior
  change: closing it would hoist a system dir over a version-manager one
  (#18234).
- posix-version-manager-bin-dirs.ts justified moving "/usr/local/bin" after the
  nvm glob with "a version manager still wins in the guest, as it does
  natively". The glob expands lexicographically; native orders nvm dirs
  default-alias-first (#10932), so that is not parity. Record the move as the
  one behavior change in the file and bound it: entries are appended behind a
  resolved login PATH, and both consumers only test presence.
- The #18234 seed-leak guard asserted only /opt/homebrew/bin and /usr/local/bin
  were absent from getVersionManagerBinPaths, leaving the four other new dirs
  unpinned. It now spells out all seven across darwin and linux -- spelled out
  rather than derived from getSystemCliInstallDirectories, which would pass
  vacuously against exactly the refactor it guards.

Tests 8 passed (8); 5 failed / 3 passed with the production hunks reverted to
origin/main. tsc node + cli clean, oxlint clean.

* fix(agents): find Pi's own installer dir in the CLI install-dir fallback

The fallback added `~/.opencode/bin` but skipped `~/.vite-plus/bin` on the
claim that no probed agent command maps to it. False: `pi` is a probed detect
command on every runtime (`tui-agent-config.ts`, no `detectUnsupportedRuntimes`)
and `~/.vite-plus/bin` is the Pi installer's default — the two dirs #829 named
and `patchPackagedProcessPath` seeds together. Added to both the native
fallback and the WSL guest prelude, so a Pi installed by its own script is
found by the `orca` CLI and in WSL, not just on a seeded packaged PATH.

Also, all narrower:
- `/snap/bin` + Linuxbrew now gate on `linux` like the seed does, instead of
  every non-darwin posix.
- Docstring: `/opt/homebrew` off darwin is the one remaining seed gap and says
  why; the "lookup-only" paragraph names the `withCliRuntimeOnPath` exception.
- New seed-order test derives the expected order from
  `getSystemCliInstallDirectories`, so reordering either list fails.

The PR body's claim that SSH hosts benefit is dropped: they answer
`preflight.detectAgents` from `src/relay/preflight-handler.ts` via
`isCommandOnPathForRelay`, a separate bundle that never reaches this module.

* fix(agents): build the CLI install-dir fallback order once and pin it on both resolvers

resolveCliCommand (every spawn site) and resolveCliCommands (detection) each
spelled the nvm -> version-manager -> system-dir order by hand, which is how
the native and WSL lists drifted apart before. One getCliInstallDirectories
now feeds both, and the test pins system dirs LAST on both resolvers, on
darwin and linux, plus a derived check that the WSL guest prelude keeps every
native version-manager dir ahead of the native system block.
2026-09-04 15:18:07 -07:00
Neil 4218d5068e fix(cli): seed nvm's default version, not the newest install (#16420)
* fix(cli): seed nvm's default version, not the newest install

#16314 stopped the login-shell probe inheriting the seeded PATH, but left the
seed itself picking the newest installed nvm version. That ordering decides
which node a CLI runs under whenever the probe does not land — a timeout, or a
login shell whose rc never initializes nvm — and newest is precisely the wrong
guess: it is usually the version the user just added and has installed nothing
into. That is the root cause reported in #10932.

Resolve `alias/default` instead, mirroring nvm: follow the alias chain
(`default` -> `lts/*` -> `lts/krypton` -> a version), resolve a partial version
like `24` to the highest matching install, and treat `system`/`node`/`stable`
as no preference. The chain is bounded and cycle-guarded because nvm's own
resolver tracks seen aliases and hand-edited files can point at each other.

Ordering is a preference, not a restriction: the remaining versions stay behind
the default, so a CLI installed outside it is still reachable.

Measured on a real machine with nvm default=24 and a bare v26.7.0 installed:
the old resolver seeds v26.7.0/bin (no CLIs), the new one seeds v24.18.0/bin
(every CLI). Tests were written first and verified to fail on the three bug
cases against main before the fix existed.

Also raise the probe budget from 5s to 10s. The old value was never measured
against a real profile: a bash -ilc loading nvm, rvm, conda and gcloud takes
~1s idle but 6-7s on a loaded machine, so a cold start under load silently
fell back to the seed. Startup does not block on the probe, and the one
awaited consumer is agent detection, which is better served by a probe that
finishes late than one that gives up early.

* fix(cli): reject non-version alias tokens instead of matching v0.x

Review finding, and a real bug I introduced. parseVersionSegment coerces
every unparseable segment to 0, so an unresolvable default alias — `garbage`,
`iojs`, `lts/nonexistent`, any hand-named alias — became [0] and prefix-matched
a `v0.12.x` install, or any stray non-version directory. Orca would then seed a
decade-old node as the preferred runtime. Real nvm answers N/A for all of them.

The `wanted.length === 0` bail could never have caught this: ''.split('.') is
[''], never empty. Replaced with a shape check that still admits legitimate
numeric prefixes — verified against nvm itself, which resolves `24` to
v24.18.0 and `0` to an installed v0.x while answering N/A for the rest.

Also corrects two comments that no longer described the code: the seed is no
longer "newest install", and the probe budget note claimed startup never blocks
on hydration, which is false on packaged Windows where it gates terminal
services and git. The traversal-guard comment claimed a containment join()
already normalizes away; the real guarantee is that matchNvmVersion can only
return an entry of the versions directory.

* fix(cli): match nvm's version-token grammar, not just its first character

Round-2 review finding, and the same bug one layer down. The previous guard
anchored only the first character, but parseInt stops at the first non-digit,
so `0x18`, `00` and `0abc` still parsed to [0] and prefix-matched a v0.12.x
install — the decade-old-node seed the earlier fix was supposed to close.

Reachable: `nvm alias default 0x18` warns that the version does not exist and
writes the alias anyway, then resolves it to N/A.

Use nvm's actual grammar, leading zeros included — nvm calls `00` and `024`
N/A while parseInt reads them as 0 and 24. Verified by executing 17 tokens
against a five-version fixture: every one now agrees with nvm, including the
legitimate prefixes `0`, `0.12`, `24` and `v24.18.0`.

Also drops a dead disjunct (the hop bound already caps the loop, so seen.size
can never exceed it) and corrects the log comment in index.ts, which still
told the reader a failed probe leaves the newest install in front. It leaves
the default version in front now, which is usually survivable but still not
what the shell would have resolved.

* test(cli): skip the lts/* chain fixture on Windows

Round-3 review finding. makeNvmHome materializes each alias as a real file,
and the chain case uses nvm's actual `lts/*` alias — `*` is a reserved Win32
filename character, so writeFileSync fails with EINVAL. PR CI runs a Windows
allowlist that excludes this file, so the breakage only reaches a Windows
developer running the suite locally.

Skipped rather than renamed: `lts/*` is the alias nvm really ships, and the
assertion pins platform: 'darwin' anyway, so the real name costs no coverage.
Matches the skipIf convention already used across src/shared.

Also reflows a comment line that a previous edit ran to 143 characters;
oxfmt does not reflow comments, so nothing would have caught it.
2026-08-25 03:05:24 -07:00
Neil a7505fd911 fix(cli): spawn a version-manager CLI with its own node runtime (#16365)
* fix(cli): spawn a version-manager CLI with its own node runtime

resolveCliCommand falls back to scanning every version-manager install when
PATH misses, so it can hand back ~/.nvm/versions/node/v20.x/bin/codex while
PATH still leads with v22. Nothing paired the binary with the runtime it was
installed against, so its `#!/usr/bin/env node` shebang loaded a v20-built
native module under a v22 ABI and the agent died on first require (#10932).

Reproduced with a real addon rather than asserted: a CLI requiring a
cpu-features build for NODE_MODULE_VERSION 115, spawned with v24 leading
PATH, fails with ERR_DLOPEN_FAILED and exit 1. With the CLI's own bin
directory prepended it runs clean.

withCliRuntimeOnPath prepends the resolved command's directory when that
directory ships a sibling node, and is a no-op otherwise — so a Homebrew or
/usr/local CLI is untouched, and the WSL paths pass a bare `codex`/`claude`
that is not absolute and so never matches.

Host CLI resolution in the Claude login path is now lazy, keeping the WSL
branch from resolving a host binary it never spawns.

* fix(cli): split PATH on the delimiter we join with, pair app-server too

Readiness review findings, all four addressed.

withCliRuntimeOnPath chose its join delimiter from the platform option but
split with the host's. Passing platform:'win32' from a posix host turned
`C:\Windows;C:\Windows\System32` into `C;\Windows;C;\Windows\System32` —
every drive letter torn off at its colon. Latent, since no shipped caller
passes platform, but the sole win32 test was written against the corrupted
value and asserted one split segment, so it green-lit the shredding.

That test's other assertion was vacuous: it seeded only `Path`, so the
`PATH` key it asserted absent could never exist. Deleting the whole
case-dedupe block left the suite green. It now seeds both keys and asserts
the full joined string; removing the block fails it.

Nothing covered the wiring, and the argument choice is the easy thing to get
silently wrong. Note it only diverges on win32 — on posix
getSpawnArgsForWindows returns the CLI itself, so pairing the spawn command
is indistinguishable there. The new test drives the win32 branch with a .cmd
fixture; pairing spawnCmd or dropping the wrapper both fail it now.

codex-trust-grant-host and codex-session-index-heal spawn the same
`codex app-server` subcommand through runCodexAppServerSession and were left
unpaired. Pair centrally there via a new optional cliPath, since
invocation.command may be a cmd.exe wrapper.

Pairing tests live in their own file: adding them inline pushed
codex-fetcher.test.ts past the 800-line ratchet.

* fix(cli): read the Windows path key the child will actually use

Round-2 review finding. The read was narrower than the delete: the key was
picked from exactly two spellings (`Path`, else `PATH`), while the twin
dedupe removed every key whose lowercase form is `path`. A block spelling it
`path` or `pATh` therefore had its value deleted without ever being read,
handing the child a PATH containing only the CLI's own directory — a strictly
worse outcome than not pairing at all.

Win32 resolves env names case-insensitively and object order preserves block
order, so the entry the child reads is the first case-insensitive match. The
repo already encodes that rule in resolvePathEnvKey
(src/main/pty/windows-path-segment-merge.ts); src/shared cannot import from
src/main, so mirror it locally.

Verified by execution across six env shapes: lowercase, mixed-case, Path-only,
PATH-only, both twins, and a PATHEXT control that must not be touched. All
preserve the original PATH; before the fix the first two lost it entirely.
Reverting the selector fails the new test and nothing else.
2026-08-24 22:30:04 -07:00
Jinwoo HongandOrcaWin 8f7692aa12 Fix packaged skills CLI runtime ownership (#11627)
* fix(cli): make packaged skills runtime self-contained

* fix(cli): address packaged skills review feedback

* ci(cli): smoke packaged skills on Windows

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-30 18:27:16 -07:00