fix(startup): stop the PATH seed pinning nvm to its newest install by nwparker · Pull Request #16314 · stablyai/orca · GitHub
Skip to content

fix(startup): stop the PATH seed pinning nvm to its newest install - #16314

Merged
nwparker merged 6 commits into
mainfrom
nwparker/codex-not-working
Aug 25, 2026
Merged

nwparker merged 6 commits into
mainfrom
nwparker/codex-not-working

Conversation

@nwparker

@nwparker nwparker commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​105 $\color{#cf222e}{\Huge{\mathbf{−}}}$​2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​103
Prod 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​86 $\color{#cf222e}{\Huge{\mathbf{−}}}$​3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​83

ELI5

nvm keeps several Node versions on your machine and lets you mark one as default. All your globally-installed CLIs (codex, claude, gemini, ...) live inside whichever version you had active when you installed them — usually the default one.

At startup, Orca tries to be helpful: "GUI apps launch with a nearly-empty PATH, so let me add the places Node tools usually live." For nvm it adds the newest version folder it can find. But newest ≠ your default. If you ever ran nvm install <something newer> and never installed your tools there, that folder is basically empty.

Then Orca asks your shell "what's your real PATH?" — and asks it while holding the note it just wrote itself. nvm reads that note, sees a Node version already selected, and politely says "great, I'll keep using that one" instead of picking your default. Orca believes the answer and hands it to every terminal pane.

So Orca's terminals end up pointed at an empty Node folder, and your CLIs vanish — while Ghostty, which never saw the note, asks the same question and gets the right answer.

The fix: stop handing the shell our own note before asking it the question. Windows already worked this way; this makes macOS/Linux match.

Problem

Typing codex in an Orca terminal fails with command not found, while the exact same command works in Ghostty/Terminal on the same machine. Same for claude, gemini, vercel, agent-slack — any globally-installed Node CLI.

Two things combine:

  1. patchPackagedProcessPath() (configure-process.ts:138) prepends getVersionManagerBinPaths() to process.env.PATH. For nvm, getNvmVersionDirectories sorts version dirs descending, so this seeds the newest installed node — not the user's default alias.
  2. Three lines later (index.ts:696 → :699), hydrateShellPath() probes the login shell with env: process.env — which now carries that seed.

nvm's startup use honors whatever node is already on PATH over the default alias:

NVM_CURRENT="$(nvm_ls_current)"
if [ "_${NVM_CURRENT}" = '_none' ] || [ "_${NVM_CURRENT}" = '_system' ]; then
  ...resolve `default`...
else
  nvm use --silent "${NVM_CURRENT}"   # <- keeps the seeded version
fi

So the probe returns a PATH pinned to the newest install, mergePathSegments promotes it to the front of Orca's PATH, and every terminal pane inherits it. Ghostty starts from the bare GUI PATH, hits the none branch, and resolves default — which is why it works.

Reproduced on my machine: nvm install 26 left a bare v26.7.0 (only node/npm/npx) while default → v24.18.0 held every global CLI. Every pane spawned after the next app launch got v26.7.0:

$ ps -Eww -p <pane> | grep -o 'node/v[0-9.]*'
node/v26.7.0          # panes from Aug 24 onward
node/v24.18.0         # every pane before the nvm install

The repo's own engines.node: 24 started failing inside Orca panes too (WARN Unsupported engine: wanted 24, current v26.7.0).

This is self-reinforcing: it re-pins on every launch and on every hydrateShellPath({ force: true }) refresh, since those also inherit the merged PATH.

Fix

Probe the login shell with the PATH the process was launched with, recorded by patchPackagedProcessPath() before it seeds. The seeds still land on process.env.PATH for Orca's own spawns — only the probe is insulated, so it reports what the user's shell actually resolves.

Windows already has the equivalent protection via WindowsShellPathOwnership.restore(); this closes the same gap on POSIX. It also lines up with the rule already documented in posix-version-manager-bin-dirs.ts ("Append, never prepend... a prepended fallback could shadow the binary the user actually runs with an older one from a stale nvm version directory") — appending alone wouldn't fix it here, because a bare GUI PATH has no other node for nvm_ls_current to find.

Tests

  • hydrate-shell-path.test.ts — probe uses the recorded launch PATH, not the seeded process.env.PATH; falls back to process.env when nothing was recorded (dev/unpackaged).
  • configure-process.test.ts — the pre-seed PATH is recorded, and recorded before the seeds land.

pnpm typecheck clean; src/main/startup/, ipc/cli, preflight/, local-agent-cli-presence suites pass (292 tests).

Verification

Replaying a real pane's env through Orca's own bash wrapper, before and after removing the stray bare install:

before:  node=v26.7.0   codex=NOTFOUND
after:   node=v24.18.0  codex=~/.nvm/versions/node/v24.18.0/bin/codex

Windows

Initially I exempted win32 from the probe insulation, on the grounds that no Windows seed pins a node version. That was wrong. getVersionManagerDirectories calls getNvmVersionDirectories on every platform (node-cli-command-resolution.ts:132-137), so a Git Bash user whose nvm uses the POSIX ~/.nvm/versions/node layout gets the newest version dir seeded on Windows too — and the Git Bash probe is -ilc, same as POSIX. Verified with a win32 fixture: getVersionManagerBinPaths({ platform: 'win32', homePath }) returns <home>/.nvm/versions/node/v22.9.0/bin first when both v20 and v22 exist.

So win32 now gets the same insulation. The one genuine Windows difference is the env key — Windows keys it Path, so recordLaunchPath records the key alongside the value and shellProbeEnv overwrites that entry in place, never leaving both Path and PATH on the spawn env. Covered by a new test.

No ordering contract

The probe's clean PATH is not handed over from the seeding site. hydrate-shell-path.ts snapshots PATH during module init, which runs while the import graph is evaluated — strictly before any statement in main's body — so it cannot observe the seeds. All seven importers are static, so no lazy import defeats it, and configure-process.ts does not import the probe module at all. There is no call ordering left for a refactor to break. A test asserts the probe ignores later process.env mutation and fails if a live read is reintroduced.

Probe diagnosability

A failed probe left the seeded newest-nvm dir in front and said nothing, so the population whose rc files blow the 5s budget hit the original symptom with no trace. It now logs [shell-path] login-shell probe failed (<failureReason>). The probe env also sets ORCA_SHELL_PATH_PROBE=1, so rc files that exec into a multiplexer or start a heavy prompt can take a fast path — the probe is an interactive login shell and previously offered no opt-out.

Verification

  • Repro and fix confirmed by replaying a real terminal pane's env through Orca's own shell wrapper: node=v26.7.0 codex=NOTFOUND before, node=v24.18.0 codex=<default>/bin/codex after.
  • Suites run natively on a Windows machine, not a mocked platform: src/main/startup/, src/main/codex-cli/, src/main/preflight/ — 39 files, 313 passed, 5 skipped.
    That run is what caught the last commit: spreading process.env yields a plain case-sensitive object while Windows resolves env names case-insensitively, so writing the captured Path back left the seeded value live under PATH. Mocked-platform tests on macOS could not see it.
  • pnpm typecheck clean; 327 tests green across the blast radius locally.

Related

Same root cause as #10932, which reported it via a different symptom (Node ABI mismatch when the shebang picks up the newest nvm node for a CLI installed under an older one). This PR should resolve that repro in the common case, but does not close it — the newest-nvm-wins policy in getNvmVersionDirectories is untouched, and the seed still wins whenever the probe does not land. Details in a comment there.

Closes #16339 and #16340 — both were folded into this PR rather than deferred.

patchPackagedProcessPath prepends the newest nvm version dir to
process.env.PATH, then hydrateShellPath probes the login shell with that
same env. nvm's startup `use` honors whatever node is already on PATH
instead of the user's `default` alias, so the probe returns a PATH pinned
to the newest install and every terminal pane inherits it.

A user whose newest nvm node is a bare install then loses every global CLI
(codex, claude, gemini, vercel...) inside Orca while they still resolve in
Ghostty/Terminal, which start from the bare GUI PATH and fall through to
`default`.

Probe with the PATH the process launched with. Windows already gets this
through WindowsShellPathOwnership.
@coderabbitai

coderabbitai Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bd2a4ee3-c103-480c-8346-856cc67d10bc

📥 Commits

Reviewing files that changed from the base of the PR and between 335a3de and 35325cf.

📒 Files selected for processing (7)
  • src/main/app-relaunch.test.ts
  • src/main/app-relaunch.ts
  • src/main/index.ts
  • src/main/startup/hydrate-shell-path.test.ts
  • src/main/startup/hydrate-shell-path.ts
  • src/main/updater.quit-and-install.test.ts
  • src/main/updater.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The startup module now captures the platform-specific launch PATH at load time and creates isolated probe environments with a marker and restored PATH. runWithLaunchPath temporarily applies the captured PATH for synchronous actions. App relaunch and updater installation use this scope. Hydration now reports failed login-shell probes and returns after successful merges. Tests cover PATH capture, probe environments, Windows key handling, relaunch, and updater behavior.

Merge Risk: ⚪ Minimal · up to 35325

The change makes shell PATH probing use the launch-time PATH so Node-based CLI resolution follows the user’s configured version instead of a newly installed version. The change is localized and tested; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides detailed problem, fix, rationale, linked issue, platform, and testing information, but it omits several required template sections, including Visual Proof/N/A, Review, Notes, … Complete the required template sections. Add N/A with a reason under Visual Proof, complete the review and boundary sections, add notes for compatibility considerations, and complete the checklist.
Linked Issues check ⚠️ Warning The implementation insulates the shell probe from seeded PATH values, but it does not satisfy issue #16339’s acceptance criteria. Seeds still run before the probe, and no explicit composition-level re… Either apply fallback seeds after probe resolution or add an explicit integration regression test at src/main/index.ts that verifies the required seed-before-probe ordering and prevents future refactors from breaking it.
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary fix: preventing startup PATH seeding from pinning nvm to its newest installation.
Out of Scope Changes check ✅ Passed The changes to app relaunch, updater installation, Windows environment handling, probe diagnostics, and related tests support launch-PATH isolation and are within the stated startup PATH objective.
Full details: Description check

Explanation

The description provides detailed problem, fix, rationale, linked issue, platform, and testing information, but it omits several required template sections, including Visual Proof/N/A, Review, Notes, Agent skill upstream boundary, and Checklist.

Full details: Linked Issues check

Explanation

The implementation insulates the shell probe from seeded PATH values, but it does not satisfy issue #16339’s acceptance criteria. Seeds still run before the probe, and no explicit composition-level regression test at src/main/index.ts verifies the ordering.

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7bdca57c-aa16-49ad-a958-0505cf3194ac

📥 Commits

Reviewing files that changed from the base of the PR and between a856367 and 8703969.

📒 Files selected for processing (4)
  • src/main/startup/configure-process.test.ts
  • src/main/startup/configure-process.ts
  • src/main/startup/hydrate-shell-path.test.ts
  • src/main/startup/hydrate-shell-path.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment thread src/main/startup/hydrate-shell-path.ts Outdated
shellProbeEnv short-circuits on win32, so the POSIX-only assertion failed
for anyone running the suite on Windows. Matches the convention in
hydrate-shell-path.windows.test.ts.

Also narrows the win32 exemption comment: WindowsShellPathOwnership
snapshots its baseline after the seeds land, so it does not unwind them.
The exemption holds because Windows keys PATH as `Path` and no Windows
seed pins a node version.
The previous commit exempted win32 on the stated grounds that no Windows
seed pins a node version. That is wrong: getVersionManagerDirectories
calls getNvmVersionDirectories on every platform, so a Git Bash user whose
nvm uses the POSIX ~/.nvm/versions/node layout gets the newest version dir
seeded on Windows too, and the -ilc Git Bash probe inherits it.

Record the PATH key alongside the value and overwrite that entry in place,
so Windows never carries both `Path` and `PATH` — which was the only real
reason to skip win32.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a148b2f-9d73-41c2-a729-9422ab6512ec

📥 Commits

Reviewing files that changed from the base of the PR and between ac4690b and 335a3de.

📒 Files selected for processing (4)
  • src/main/startup/configure-process.test.ts
  • src/main/startup/configure-process.ts
  • src/main/startup/hydrate-shell-path.test.ts
  • src/main/startup/hydrate-shell-path.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Comment thread src/main/startup/configure-process.ts Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the first recorded launch PATH.

recordLaunchPath overwrites launchPath on every call. If patchPackagedProcessPath() runs twice, the second call records the seeded PATH and can reintroduce nvm version pinning. Record only when launchPath === null, and add a two-call regression test.

#!/bin/bash
set -euo pipefail
rg -n -P '\bpatchPackagedProcessPath\s*\(' --glob '*.ts' --glob '*.tsx' .

Comment thread src/main/startup/hydrate-shell-path.test.ts Outdated
…ures

Three loose ends from the review, folded in rather than deferred.

The probe's clean PATH was handed over by an explicit recordLaunchPath call
from the seeding site, so the invariant lived across three files and a
refactor that moved the seed call would silently re-pin nvm. Snapshot PATH
during module init instead: that runs while the import graph is evaluated,
strictly before any statement in main's body, so it cannot observe the
seeds and there is no call ordering left to break. All seven importers are
static, so no lazy import can defeat it. A test asserts the probe ignores
later process.env mutation, and fails if the live read is reintroduced.

A failed startup probe leaves the seeded newest-nvm dir in front and said
nothing, so the population whose rc files blow the 5s budget hit the
original symptom with no diagnosable trace. Log the failureReason.

The probe is an interactive login shell, so rc files that exec into a
multiplexer or start a heavy prompt can outrun that budget with no way to
opt out. Set ORCA_SHELL_PATH_PROBE=1 so they can take a fast path.
Caught by running the suite on a real Windows machine, not a mocked
platform. The spread of process.env is a plain, case-sensitive object,
while Windows resolves env names case-insensitively. Writing the captured
`Path` back onto it left the seeded value still live under `PATH`, so the
probe shell could read either one — the exact duplicate-key hazard the
win32 branch was supposed to prevent.

Drop any other-cased variant of the key before writing.
@nwparker
nwparker merged commit 5c116b6 into main Aug 25, 2026
3 checks passed
nwparker added a commit that referenced this pull request Aug 25, 2026
* 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.
paidaxingyo666 pushed a commit to paidaxingyo666/Manta that referenced this pull request Aug 25, 2026
…tablyai#16314)

* fix(startup): stop the PATH seed pinning nvm to its newest install

patchPackagedProcessPath prepends the newest nvm version dir to
process.env.PATH, then hydrateShellPath probes the login shell with that
same env. nvm's startup `use` honors whatever node is already on PATH
instead of the user's `default` alias, so the probe returns a PATH pinned
to the newest install and every terminal pane inherits it.

A user whose newest nvm node is a bare install then loses every global CLI
(codex, claude, gemini, vercel...) inside Orca while they still resolve in
Ghostty/Terminal, which start from the bare GUI PATH and fall through to
`default`.

Probe with the PATH the process launched with. Windows already gets this
through WindowsShellPathOwnership.

* test(startup): pin the platform in the probe-env test

shellProbeEnv short-circuits on win32, so the POSIX-only assertion failed
for anyone running the suite on Windows. Matches the convention in
hydrate-shell-path.windows.test.ts.

Also narrows the win32 exemption comment: WindowsShellPathOwnership
snapshots its baseline after the seeds land, so it does not unwind them.
The exemption holds because Windows keys PATH as `Path` and no Windows
seed pins a node version.

* fix(startup): give win32 the same probe insulation, keyed by Path

The previous commit exempted win32 on the stated grounds that no Windows
seed pins a node version. That is wrong: getVersionManagerDirectories
calls getNvmVersionDirectories on every platform, so a Git Bash user whose
nvm uses the POSIX ~/.nvm/versions/node layout gets the newest version dir
seeded on Windows too, and the -ilc Git Bash probe inherits it.

Record the PATH key alongside the value and overwrite that entry in place,
so Windows never carries both `Path` and `PATH` — which was the only real
reason to skip win32.

* fix(startup): snapshot the launch PATH at module load, log probe failures

Three loose ends from the review, folded in rather than deferred.

The probe's clean PATH was handed over by an explicit recordLaunchPath call
from the seeding site, so the invariant lived across three files and a
refactor that moved the seed call would silently re-pin nvm. Snapshot PATH
during module init instead: that runs while the import graph is evaluated,
strictly before any statement in main's body, so it cannot observe the
seeds and there is no call ordering left to break. All seven importers are
static, so no lazy import can defeat it. A test asserts the probe ignores
later process.env mutation, and fails if the live read is reintroduced.

A failed startup probe leaves the seeded newest-nvm dir in front and said
nothing, so the population whose rc files blow the 5s budget hit the
original symptom with no diagnosable trace. Log the failureReason.

The probe is an interactive login shell, so rc files that exec into a
multiplexer or start a heavy prompt can outrun that budget with no way to
opt out. Set ORCA_SHELL_PATH_PROBE=1 so they can take a fast path.

* fix(startup): drop the other-cased PATH key from the Windows probe env

Caught by running the suite on a real Windows machine, not a mocked
platform. The spread of process.env is a plain, case-sensitive object,
while Windows resolves env names case-insensitively. Writing the captured
`Path` back onto it left the seeded value still live under `PATH`, so the
probe shell could read either one — the exact duplicate-key hazard the
win32 branch was supposed to prevent.

Drop any other-cased variant of the key before writing.

* fix(startup): preserve launch PATH across app restarts

(cherry picked from commit 5c116b6)
dallascrilley pushed a commit to dallascrilley/orca that referenced this pull request Aug 27, 2026
…tablyai#16314)

* fix(startup): stop the PATH seed pinning nvm to its newest install

patchPackagedProcessPath prepends the newest nvm version dir to
process.env.PATH, then hydrateShellPath probes the login shell with that
same env. nvm's startup `use` honors whatever node is already on PATH
instead of the user's `default` alias, so the probe returns a PATH pinned
to the newest install and every terminal pane inherits it.

A user whose newest nvm node is a bare install then loses every global CLI
(codex, claude, gemini, vercel...) inside Orca while they still resolve in
Ghostty/Terminal, which start from the bare GUI PATH and fall through to
`default`.

Probe with the PATH the process launched with. Windows already gets this
through WindowsShellPathOwnership.

* test(startup): pin the platform in the probe-env test

shellProbeEnv short-circuits on win32, so the POSIX-only assertion failed
for anyone running the suite on Windows. Matches the convention in
hydrate-shell-path.windows.test.ts.

Also narrows the win32 exemption comment: WindowsShellPathOwnership
snapshots its baseline after the seeds land, so it does not unwind them.
The exemption holds because Windows keys PATH as `Path` and no Windows
seed pins a node version.

* fix(startup): give win32 the same probe insulation, keyed by Path

The previous commit exempted win32 on the stated grounds that no Windows
seed pins a node version. That is wrong: getVersionManagerDirectories
calls getNvmVersionDirectories on every platform, so a Git Bash user whose
nvm uses the POSIX ~/.nvm/versions/node layout gets the newest version dir
seeded on Windows too, and the -ilc Git Bash probe inherits it.

Record the PATH key alongside the value and overwrite that entry in place,
so Windows never carries both `Path` and `PATH` — which was the only real
reason to skip win32.

* fix(startup): snapshot the launch PATH at module load, log probe failures

Three loose ends from the review, folded in rather than deferred.

The probe's clean PATH was handed over by an explicit recordLaunchPath call
from the seeding site, so the invariant lived across three files and a
refactor that moved the seed call would silently re-pin nvm. Snapshot PATH
during module init instead: that runs while the import graph is evaluated,
strictly before any statement in main's body, so it cannot observe the
seeds and there is no call ordering left to break. All seven importers are
static, so no lazy import can defeat it. A test asserts the probe ignores
later process.env mutation, and fails if the live read is reintroduced.

A failed startup probe leaves the seeded newest-nvm dir in front and said
nothing, so the population whose rc files blow the 5s budget hit the
original symptom with no diagnosable trace. Log the failureReason.

The probe is an interactive login shell, so rc files that exec into a
multiplexer or start a heavy prompt can outrun that budget with no way to
opt out. Set ORCA_SHELL_PATH_PROBE=1 so they can take a fast path.

* fix(startup): drop the other-cased PATH key from the Windows probe env

Caught by running the suite on a real Windows machine, not a mocked
platform. The spread of process.env is a plain, case-sensitive object,
while Windows resolves env names case-insensitively. Writing the captured
`Path` back onto it left the seeded value still live under `PATH`, so the
probe shell could read either one — the exact duplicate-key hazard the
win32 branch was supposed to prevent.

Drop any other-cased variant of the key before writing.

* fix(startup): preserve launch PATH across app restarts
dallascrilley pushed a commit to dallascrilley/orca that referenced this pull request Aug 27, 2026
…i#16420)

* fix(cli): seed nvm's default version, not the newest install

stablyai#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 stablyai#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.
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…tablyai#16314)

* fix(startup): stop the PATH seed pinning nvm to its newest install

patchPackagedProcessPath prepends the newest nvm version dir to
process.env.PATH, then hydrateShellPath probes the login shell with that
same env. nvm's startup `use` honors whatever node is already on PATH
instead of the user's `default` alias, so the probe returns a PATH pinned
to the newest install and every terminal pane inherits it.

A user whose newest nvm node is a bare install then loses every global CLI
(codex, claude, gemini, vercel...) inside Orca while they still resolve in
Ghostty/Terminal, which start from the bare GUI PATH and fall through to
`default`.

Probe with the PATH the process launched with. Windows already gets this
through WindowsShellPathOwnership.

* test(startup): pin the platform in the probe-env test

shellProbeEnv short-circuits on win32, so the POSIX-only assertion failed
for anyone running the suite on Windows. Matches the convention in
hydrate-shell-path.windows.test.ts.

Also narrows the win32 exemption comment: WindowsShellPathOwnership
snapshots its baseline after the seeds land, so it does not unwind them.
The exemption holds because Windows keys PATH as `Path` and no Windows
seed pins a node version.

* fix(startup): give win32 the same probe insulation, keyed by Path

The previous commit exempted win32 on the stated grounds that no Windows
seed pins a node version. That is wrong: getVersionManagerDirectories
calls getNvmVersionDirectories on every platform, so a Git Bash user whose
nvm uses the POSIX ~/.nvm/versions/node layout gets the newest version dir
seeded on Windows too, and the -ilc Git Bash probe inherits it.

Record the PATH key alongside the value and overwrite that entry in place,
so Windows never carries both `Path` and `PATH` — which was the only real
reason to skip win32.

* fix(startup): snapshot the launch PATH at module load, log probe failures

Three loose ends from the review, folded in rather than deferred.

The probe's clean PATH was handed over by an explicit recordLaunchPath call
from the seeding site, so the invariant lived across three files and a
refactor that moved the seed call would silently re-pin nvm. Snapshot PATH
during module init instead: that runs while the import graph is evaluated,
strictly before any statement in main's body, so it cannot observe the
seeds and there is no call ordering left to break. All seven importers are
static, so no lazy import can defeat it. A test asserts the probe ignores
later process.env mutation, and fails if the live read is reintroduced.

A failed startup probe leaves the seeded newest-nvm dir in front and said
nothing, so the population whose rc files blow the 5s budget hit the
original symptom with no diagnosable trace. Log the failureReason.

The probe is an interactive login shell, so rc files that exec into a
multiplexer or start a heavy prompt can outrun that budget with no way to
opt out. Set ORCA_SHELL_PATH_PROBE=1 so they can take a fast path.

* fix(startup): drop the other-cased PATH key from the Windows probe env

Caught by running the suite on a real Windows machine, not a mocked
platform. The spread of process.env is a plain, case-sensitive object,
while Windows resolves env names case-insensitively. Writing the captured
`Path` back onto it left the seeded value still live under `PATH`, so the
probe shell could read either one — the exact duplicate-key hazard the
win32 branch was supposed to prevent.

Drop any other-cased variant of the key before writing.

* fix(startup): preserve launch PATH across app restarts
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…i#16420)

* fix(cli): seed nvm's default version, not the newest install

stablyai#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 stablyai#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.
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.

PATH seeding runs before the login-shell probe, creating a 3-file ordering contract

1 participant