Skip to content

fix(codex): pair structured launches with the CLI runtime - #32061

Merged
nwparker merged 5 commits into
mainfrom
nwparker/codex-structured-cli-runtime
Oct 23, 2026
Merged

nwparker merged 5 commits into
mainfrom
nwparker/codex-structured-cli-runtime

Conversation

@nwparker

@nwparker nwparker commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​79 $\color{#cf222e}{\Huge{\mathbf{−}}}$​8 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​71
Prod 5 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​62 $\color{#cf222e}{\Huge{\mathbf{−}}}$​27 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​35

ELI5

Codex installed with npm is a small JavaScript file, so it needs node to run. When Orca starts a Codex chat, it may find Codex inside one NVM Node version folder (for example ~/.nvm/versions/node/v26.7.0/bin/codex), but the chat process's PATH may have no node at all, or a different one. Before this PR, the chat then failed with env: node: No such file or directory and the user saw "Unable to start chat". Now Orca puts the node that sits next to the Codex it found on the chat's PATH. Claude chats and one-shot Codex runs already did this.

This PR also makes sure Orca's own orca command stays first on the chat's PATH, ahead of that Node folder. Without that, a folder holding node, codex and an installed orca (for example /usr/local/bin on Intel Macs, or /usr/bin on Linux) could make the chat call a different Orca build's orca. Claude chats had this ordering bug too; it is fixed in the same shared function.

A Codex that is a compiled program (Homebrew, the standalone installer, a Linux package) never runs on node, so Orca now leaves PATH alone for it even when a node happens to sit in the same folder. And if the env Orca builds names no PATH at all, Orca keeps the PATH the chat would inherit instead of replacing it with just the Codex folder.

What Changed

  • codex-structured-launch-resolution.ts: pass the resolved Codex command as cliRuntimeCommand, as Claude already does.

  • structured-session-child-env.ts: sealStructuredSessionChild pairs the runtime before it adds this app's CLI dir, so PATH becomes <orca-cli>/bin : <codex's node dir> : <inherited PATH>. Before, the runtime dir went ahead of the Orca CLI dir. The doc comment already said "after the identity's", and the existing DEV_CLI_BIN_FIRST assertions require the CLI dir to be first.

  • Snapshots: the Codex parity snapshots now match main again. The five default Claude cases change back to Orca CLI first.

  • Tests: one real-process POSIX test starts a shebang CLI that would fail under the wrong node. It checks the PATH order is CLI dir, then runtime dir, then the inherited PATH. One redundant PATH-shape test was removed because the parity snapshots already cover the sibling and no-sibling cases.

  • withCliRuntimeOnPath (shared by every Node-CLI pairing caller): skips a command whose first bytes are ELF, Mach-O or PE (new src/shared/cli-runtime-pairing-inputs.ts, which also takes firstWindowsPathEnvKey out of the over-limit resolver file). When the env has no PATH key it now pairs over process.env's PATH, the one the child inherits, matching readInheritedPath. Before, it wrote PATH=<cli dir> and the inherited PATH was lost. Two unit tests in src/main/codex-cli/command.test.ts cover both cases.

Linked Issue

Related to #27502. This fixes the pairing gap in the persistent launch. The issue's other cause, a stale or broken codex shim earlier on PATH that blocks the fallback to a working NVM install, is not fixed here, so the issue stays open.

Verification

  • Real Codex, red/green (macOS): I wrote a throwaway probe (not checked in). It ran the real composed Codex launch and a real codex app-server handshake (initialize only: no model requests, scratch CODEX_HOME) against the real npm install ~/.nvm/versions/node/v26.7.0/bin/codex (codex-cli 0.150.0).
    • With PATH /usr/bin:/bin and no node: without the pairing line it fails with env: node: No such file or directory. With the fix the handshake succeeds.
    • With a fake node that exits 79 earlier on PATH: without the fix it fails with WRONG-NODE. With the fix the handshake succeeds.
    • The child PATH with the fix was <orca-dev cli>/bin:~/.nvm/versions/node/v26.7.0/bin:..., so the Orca CLI stays first.
  • Unit tests (macOS): pnpm test src/main/codex src/main/claude/claude-structured-launch-resolution.test.ts src/main/runtime/structured-agent-launch-parity.test.ts: 293 files, 2988 tests pass. The shebang test fails if the pairing line is removed.
  • Linux (omarchy, x86_64, Node 26.8.1): fresh clone of this branch, then pnpm test on the Codex child-environment, Claude launch-resolution and parity suites: 92/92 pass. This includes the POSIX shebang test.
  • Windows 11 (awin, Git Bash, Node from C:\Program Files\nodejs, codex from npm at %APPDATA%\npm\codex.cmd):
    • pnpm test on the Codex child-environment, Claude launch-resolution and parity suites: the same 4 tests fail on this branch and on main (they expect orca-dev but Windows gets orca-dev.cmd). This PR adds no new failures. The shebang test is skipped on Windows, as intended.
    • Real install: Orca finds codex.cmd, which has no node.exe next to it, so nothing is added. Path still starts with the Orca CLI dir.
    • Throwaway probe, not checked in: an nvm-windows-style folder holding node.exe, codex.cmd and a junction to the real npm node_modules. A fake node.cmd that exits 79 comes first on Path, and a stale PATH key sits next to Path. On this branch, the composed env has a single Path key, ordered <orca cli>\bin;<codex folder>;<competing>;.... The real codex.cmd app-server initialize handshake succeeds. On main (with the two source files swapped in), the folder is not added and both Path and PATH keys are left in the env.
  • Native-binary skip and PATH-less env, after the follow-up commit: I bundled the real withCliRuntimeOnPath with esbuild into a throwaway probe (not checked in) and ran it on real installs:
    • macOS: the real Mach-O ~/.local/bin/codex, symlinked beside a node, leaves PATH alone. The npm ~/.nvm/versions/node/v26.7.0/bin/codex still pairs. With an empty env, the result is <codex dir>:<inherited PATH>, not just <codex dir>.
    • Linux (omarchy): the real ELF codex (mise) beside a node leaves PATH alone. A #!/usr/bin/env node script beside mise's node pairs, runs on that node, and keeps the inherited PATH.
    • Windows (awin, over LAN SSH): the real %APPDATA%\npm\codex.cmd has no sibling node.exe, so nothing changes. A codex.cmd beside a copied node.exe pairs (<dir>;C:\Windows). A PE .exe beside node.exe leaves PATH alone.
  • Upstream Codex (openai/codex main, read only, not run): tool commands get their env from shell_environment_policy. Both inherit = "all" and "core" keep PATH (UNIX_CORE_ENV_VARS / WINDOWS_CORE_ENV_VARS in protocol/src/shell_environment.rs). The shell_snapshot feature is stable and on by default. It captures PATH from an interactive login shell started with Codex's env, so the user's rc files (nvm use, brew shellenv, macOS path_helper) run on top of our PATH and usually put their own entries in front again. In other words, a tool command sees PATH the way a new terminal started from that env would.
  • Re-verified after rebasing onto main 28c7268, which includes fix(runtime): main lints and typechecks again after #32018 #32168 (head 63d6b63, no conflicts):
    • Targeted pnpm test over src/main/codex, src/main/claude, src/main/codex-cli, src/main/rate-limits, src/main/text-generation, src/main/agent-session, structured-session-child-env and the node-cli resolver: 567 files, 5901 tests pass. The parity suite passes too (56/56). oxlint and oxfmt are clean on the changed files.
    • macOS, real installs, run through the real withCliRuntimeOnPath with tsx: the npm @openai/codex (bin/codex.js, #!/usr/bin/env node) symlinked beside a node pairs, giving <dir>:/usr/bin:/bin. A Mach-O copied beside the same node leaves PATH alone.
    • Linux (omarchy), new finding: mise's codex and node shims share a folder, and each shim is an ELF (/usr/bin/mise), so this PR no longer pairs that folder. I checked this is harmless. env -i PATH=/usr/bin:/bin ~/.local/share/mise/shims/codex --version prints codex-cli 0.160.0. Inside a shim, mise puts its own node install on PATH (mise exec -- command -v node gives .../mise/installs/node/26.8.1/bin/node). The codex mise runs is itself an ELF.
    • Windows (awin, in my own throwaway Orca worktree, since removed): the real %APPDATA%\npm\codex.cmd starts @ECH, so it is not native and behaves as before. There is no node.exe beside it. The real claude.exe and node.exe start 4d 5a (MZ), so they are detected as native.
  • CI: the earlier red Lint gate came from main itself (headless-terminal-tab-close.ts was over max-lines after fix(runtime): reject unsupported renderer-owned tab properties #32018). fix(runtime): main lints and typechecks again after #32018 #32168 fixed that, and this branch is now rebased on top of it. pnpm tc is clean (node, cli and web), and oxlint is clean on the changed files plus that file.

Risks / Tradeoffs

  • The chat process's PATH now has Codex's own Node folder near the front (only for a Node-script Codex beside a node; compiled binaries are skipped), just as a terminal has when that NVM version is active. Commands Codex runs under shell_environment_policy inherit = "core" see that PATH unless their login shell rebuilds it. Homebrew, standalone and distro-package Codex builds are compiled binaries, so they are no longer affected. Codex's default shell snapshot re-runs the user's rc files over this PATH, so nvm/brew setups usually reorder it again for tool commands. Bare commands and installs without that sibling node behave exactly as before. A native shim beside a node shim (mise) is no longer paired; mise supplies its own node (checked on omarchy above).
  • Claude chats get the corrected order (Orca CLI first, runtime second). The runtime pairing itself is unchanged.
  • Windows: npm .cmd shims already prefer a node.exe next to them, so on Windows this mostly changes Path order (checked above). Not exercised: WSL, and SSH/remote hosts, which compose their launches on the host. Mobile and the wire protocol are unaffected: this only changes the local child environment.

Visual Proof

N/A: this only changes the process environment. Nothing in the UI changes.

Related contributor PRs

None found. #10932 (closed) covered the general NVM CLI/runtime pairing.

@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0320fbf5-de15-449b-8d73-b716e51feeb5
📥 Commits

Reviewing files that changed from the base of the PR and between 3306b2c88a07e42d398e328ec537e69261075cb1 and 51837a8ba2c9da4f726f6eed53a855c4b6cae3c7.

📒 Files selected for processing: 1
  • src/main/codex-cli/command.test.ts
🚧 Files skipped from review as they are similar to previous changes: 1
  • src/main/codex-cli/command.test.ts

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

📝 Walkthrough

Runtime pairing now skips compiled native CLIs and uses the inherited process PATH when the supplied environment has no PATH value. Structured child environments pair the optional CLI runtime with the input environment before building the child identity. The Codex launch-part result includes the resolved command. Tests cover PATH ordering and startup of a shebang-based CLI with a competing Node executable earlier on PATH.

Priority: ⬇️ Low

Merge Risk: 🔵 Low · up to 51837

The PATH-ordering tests add no new identified issue, but Windows launches with differently cased PATH entries may lose required directories. This is a bounded risk to resolve or explicitly accept before merging.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title is concise, specific, and accurately describes the primary change: pairing structured Codex launches with the CLI runtime.
Docstring Coverage Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 8 files.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description is detailed and covers the change, motivation, linked issue, testing across platforms, visual proof, risks, and known limitations. It omits some template headings, including Why, AI Di…
Full details: Description check

Explanation

The description is detailed and covers the change, motivation, linked issue, testing across platforms, visual proof, risks, and known limitations. It omits some template headings, including Why, AI Disclosure, Review, Agent skill upstream boundary, Notes, and Checklist, but the required technical information is largely present.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@nwparker
nwparker force-pushed the nwparker/codex-structured-cli-runtime branch from 2229d76 to 3306b2c Compare October 23, 2026 03:38

@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
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c969f4b4-6c66-43cc-ba00-167e1a587147
📥 Commits

Reviewing files that changed from the base of the PR and between b7d5ad6 and 3306b2c88a07e42d398e328ec537e69261075cb1.

⛔ Files ignored due to path filters: 1
  • src/main/runtime/__snapshots__/structured-agent-launch-parity.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing: 7
  • src/main/claude/claude-structured-child-env.ts
  • src/main/claude/claude-structured-launch-resolution.test.ts
  • src/main/codex-cli/command.test.ts
  • src/main/codex/codex-structured-child-environment.test.ts
  • src/main/runtime/structured-session-child-env.ts
  • src/shared/cli-runtime-pairing-inputs.ts
  • src/shared/node-cli-command-resolution.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.

Comment on lines +37 to +39
for (const key of Object.keys(env)) {
if (key.toLowerCase() === 'path' && env[key] !== undefined) {
return key

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

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,110p' src/shared/cli-runtime-pairing-inputs.ts
sed -n '325,385p' src/shared/node-cli-command-resolution.ts
rg -n 'duplicate|Path:|PATH:|firstWindowsPathEnvKey|withCliRuntimeOnPath|sealStructuredSessionChild' src/main/codex-cli/command.test.ts src/main/runtime/structured-session-child-env.ts src/shared

Repository: stablyai/orca

Length of output: 41239


🌐 Web query:

Node.js 24 child_process spawn env Windows case insensitive duplicate PATH keys lexicographic sorted first only

💡 Result:

<source_evidence>
<source kind="find_in_page">
<title>node/lib/child_process.js at v24.0.0 · nodejs/node · GitHub</title>
<location>https://github.com/nodejs/node/blob/v24.0.0/lib/child_process.js</location>
<excerpt>citeturn2view0 [wordlim: 200] Crawled: today; Content type: text/html; Source: find({&quot;ref_id&quot;:&quot;turn1view1&quot;,&quot;pattern&quot;:&quot;envPairs&quot;}); Total lines: 3868
L3259: file = &#39;/system/bin/sh&#39;;
L3260: 
L3261: else
L3262: 
L3263: file = &#39;/bin/sh&#39;;
L3264: 
L3265: args = [&#39;-c&#39;, command];
L3266: 
L3267: }
L3268: 
L3269: }
L3270: 
L3271: if (typeof options.argv0 === &#39;string&#39;) {
L3272: 
L3273: ArrayPrototypeUnshift(args, options.argv0);
L3274: 
L3275: } else {
L3276: 
L3277: ArrayPrototypeUnshift(args, file);
L3278: 
L3279: }
L3280: 
L3281: const env = options.env || process.env;
L3282: 
L3283: const envPairs = [];
L3284: 
L3285: // process.env.NODE_V8_COVERAGE always propagates, making it possible to
L3286: 
L3287: // collect coverage for programs that spawn with white-listed environment.
L3288: 
L3289: copyProcessEnvToEnv(env, &#39;NODE_V8_COVERAGE&#39;, options.env);
L3290: 
L3291: if (isZOS) {
L3292: // The following environment variables must always propagate if set.
L3293: 
L3294: copyProcessEnvToEnv(env, &#39;_BPXK_AUTOCVT&#39;, options.env);
L3295: 
L3296: copyProcessEnvToEnv(env, &#39;_CEE_RUNOPTS&#39;, options.env);
L3297: 
L3298: copyProcessEnvToEnv(env, &#39;_TAG_REDIR_ERR&#39;, options.env);
L3299: 
L3300: copyProcessEnvToEnv(env, &#39;_TAG_REDIR_IN&#39;, options.env);
L3301: 
L3302: copyProcessEnvToEnv(env, &#39;_TAG_REDIR_OUT&#39;, options.env);
L3303: 
L3304: copyProcessEnvToEnv(env, &#39;STEPLIB&#39;, options.env);
L3305: 
L3306: copyProcessEnvToEnv(env, &#39;LIBPATH&#39;, options.env);
L3307: copyProcessEnvToEnv(env, &#39;_EDC_SIG_DFLT&#39;, options.env);
L3308: 
L3309: copyProcessEnvToEnv(env, &#39;_EDC_SUSV3&#39;, options.env);
L3310: 
L3311: }
L3312: 
L3313: let envKeys = [];
L3314: 
L3315: // Prototype values are intentionally included.
L3316: 
L3317: for (const key in env) {
L3318: 
L3319: ArrayPrototypePush(envKeys, key);
L3320: 
L3321: }
L3322: 
L3323: if (process.platform === &#39;win32&#39;) {
L3324: 
L3325: // On Windows env keys are case insensitive. Filter out duplicates,
L3326: 
L3327: // keeping only the first one (in lexicographic order)
L3328: 
L3329: const sawKey = new SafeSet();
L3330: 
L3331: envKeys = ArrayPrototypeFilter(
L3332: 
L3333: ArrayPrototypeSort(envKeys),
L3334: (key) =&gt; {
L3335: 
L3336: const uppercaseKey = StringPrototypeToUpperCase(key);
L3337: 
L3338: if (sawKey.has(uppercaseKey)) {
L3339: 
L3340: return false;
L3341: 
L3342: }
L3343: 
L3344: sawKey.add(uppercaseKey);
L3345: 
L3346: return true;
L3347: 
L3348: },
L3349: 
L3350: );
L3351: 
L3352: }
L3353: 
L3354: for (const key of envKeys) {
L3355: 
L3356: const value = env[key];
L3357: 
L3358: if (value !== undefined) {
L3359: 
L3360: validateArgumentNullCheck(key, `options.env[&#39;${key}&#39;]`);
L3361: 
L3362: validateArgumentNullCheck(value, `options.env[&#39;${key}&#39;]`);
L3363: 
L3364: ArrayPrototypePush(envPairs, `${key}=${value}`);
L3365: 
L3366: }
L3367: 
L3368: }
L3369: 
L3370: return {
L3371: 
L3372: // Make a shallow copy so we don&#39;t clobber the user&#39;s options object.
L3373: __proto__: null,
L3374: 
L3375: ...options,
L3376: 
L3377: args,
L3378: 
L3379: cwd,
L3380: 
L3381: detached: !!options.detached,
L3382: 
L3383: envPairs,
L3384: 
L3385: file,
L3386: 
L3387: windowsHide: !!options.windowsHide,
L3388: 
L3389: windowsVerbatimArguments: !!windowsVerbatimArguments,
L3390: 
L3391: };
L3392: 
L3393: }
L3394: 
L3395: function abortChildProcess(child, killSignal, reason) {
L3396: 
L3397: if (!child)
L3398: 
L3399: return;
L3400: 
L3401: try {
L3402: 
L3403: if (child.kill(killSignal)) {
L3404: 
L3405: child.emit(&#39;error&#39;, new AbortError(undefined, { cause: reason }));
L3406: 
L3407: }
L3408: 
L3409: } catch (err) {
L3410: 
L3411: child.emit(&#39;error&#39;, err);
L3412: 
L3413: }
L3414: 
L3415: }
L3416: 
L3417: /**
L3418: 
L3419: * Spawns a new process using the given `file`.
L3420: 
L3421: * @param {string} file
L3422: * @param {…[truncated]</excerpt>
</source>
<source kind="find_in_page">
<title>node/deps/uv/src/win/process.c at v24.0.0 · nodejs/node · GitHub</title>
<location>https://github.com/nodejs/node/blob/v24.0.0/deps/uv/src/win/process.c</location>
<excerpt>citeturn2view2 [wordlim: 200] Crawled: today; Content type: text/html; Source: find({&quot;ref_id&quot;:&quot;turn1view2&quot;,&quot;pattern&quot;:&quot;env_block&quot;}); Total lines: 3838
L3213: * The way windows takes environment variables is different than what C does;
L3214: 
L3215: * Windows wants a contiguous block of null-terminated strings, terminated
L3216: 
L3217: * with an additional null.
L3218: 
L3219: *
L3220: 
L3221: * Windows has a few &quot;essential&quot; environment variables. winsock will fail
L3222: 
L3223: * to initialize if SYSTEMROOT is not defined; some APIs make reference to
L3224: 
L3225: * TEMP. SYSTEMDRIVE is probably also important. We therefore ensure that
L3226: 
L3227: * these get defined if the input environment block does not contain any
L3228: 
L3229: * values for them.
L3230: 
L3231: *
L3232: * Also add variables known to Cygwin to be required for correct
L3233: 
L3234: * subprocess operation in many cases:
L3235: 
L3236: * https://github.com/Alexpux/Cygwin/blob/b266b04fbbd3a595f02ea149e4306d3ab9b1fe3d/winsup/cygwin/environ.cc#L955
L3237: 
L3238: *
L3239: 
L3240: */
L3241: 
L3242: int make_program_env(char* env_block[], WCHAR** dst_ptr) {
L3243: 
L3244: WCHAR* dst;
L3245: 
L3246: WCHAR* ptr;
L3247: 
L3248: char** env;
L3249: 
L3250: size_t env_len = 0;
L3251: 
L3252: size_t len;
L3253: 
L3254: size_t i;
L3255: 
L3256: size_t var_size;
L3257: 
L3258: size_t env_block_count = 1; /* 1 for null-terminator */
L3259: 
L3260: WCHAR* dst_copy;
L3261: 
L3262: WCHAR** ptr_copy;
L3263: 
L3264: WCHAR** env_copy;
L3265: 
L3266: char* p;
L3267: size_t required_vars_value_len[ARRAY_SIZE(required_vars)];
L3268: 
L3269: /* first pass: determine size in UTF-16 */
L3270: 
L3271: for (env = env_block; *env; env++) {
L3272: 
L3273: ssize_t len;
L3274: 
L3275: if (strchr(*env, &#39;=&#39;)) {
L3276: 
L3277: len = uv_wtf8_length_as_utf16(*env);
L3278: 
L3279: if (len &lt; 0)
L3280: 
L3281: return len;
L3282: 
L3283: env_len += len;
L3284: 
L3285: env_block_count++;
L3286: 
L3287: }
L3288: 
L3289: }
L3290: 
L3291: /* second pass: copy to UTF-16 environment block */
L3292: 
L3293: len = env_block_count * sizeof(WCHAR*);
L3294: 
L3295: p = uv__malloc(len + env_len * sizeof(WCHAR));
L3296: 
L3297: if (p == NULL) {
L3298: 
L3299: return UV_ENOMEM;
L3300: 
L3301: }
L3302: 
L3303: env_copy = (void*) &amp;p[0];
L3304: dst_copy = (void*) &amp;p[len];
L3305: 
L3306: ptr = dst_copy;
L3307: 
L3308: ptr_copy = env_copy;
L3309: 
L3310: for (env = env_block; *env; env++) {
L3311: 
L3312: ssize_t len;
L3313: 
L3314: if (strchr(*env, &#39;=&#39;)) {
L3315: 
L3316: len = uv_wtf8_length_as_utf16(*env);
L3317: 
L3318: assert(len &gt; 0);
L3319: 
L3320: assert((size_t) len &lt;= env_len - (ptr - dst_copy));
L3321: 
L3322: uv_wtf8_to_utf16(*env, ptr, len);
L3323: 
L3324: *ptr_copy++ = ptr;
L3325: 
L3326: ptr += len;
L3327: 
L3328: }
L3329: 
L3330: }
L3331: 
L3332: *ptr_copy = NULL;
L3333: 
L3334: assert(env_len == 0 || env_len == (size_t) (ptr - dst_copy));
L3335: 
L3336: /* sort our (UTF-16) copy */
L3337: 
L3338: qsort(env_copy, env_block_count-1, sizeof(wchar_t*), qsort_wcscmp);
L3339: /* third pass: check for required variables */
L3340: 
L3341: for (ptr_copy = env_copy, i = 0; i &lt; ARRAY_SIZE(required_vars); ) {
L3342: 
L3343: int cmp;
L3344: 
L3345: if (!*ptr_copy) {
L3346: 
L3347: cmp = -1;
L3348: 
L3349: } else {
L3350: 
L3351: cmp = env_strncmp(required_vars[i].wide_eq,
L3352: 
L3353: required_vars[i].len,
L3354: 
L3355: *ptr_copy);
L3356: 
L3357: }
L3358: 
L3359: if (cmp &lt; 0) {
L3360: 
L3361: /* missing required var */
L3362: 
L3363: var_size = GetEnvironmentVariableW(required_vars[i].wide, NULL, 0);
L3364: 
L3365: required_vars_value_len[i] = var_size;
L3366: 
L3367: if (var_size != 0) {
L3368: 
L3369: env_len += required_vars[i].len;
L3370: 
L3371: env_len += var_size;
L3372: 
L3373: }
L3374: 
L3375: i++;
L3376: 
L3377: } else {
L3378: ptr_copy++;
L3379: 
L3380: if (cmp == 0)
L3381: 
L3382: i++;
L3383:…[truncated]</excerpt>
</source>
<source kind="open_page">
<title>node/lib/child_process.js at v24.0.0 · nodejs/node · GitHub</title>
<location>https://github.com/nodejs/node/blob/v24.0.0/lib/child_process.js</location>
<excerpt>citeturn1view1 [wordlim: 200] Crawled: today; Content type: text/html; Source: open({&quot;ref_id&quot;:&quot;https://github.com/nodejs/node/blob/v24.0.0/lib/child_process.js&quot;,&quot;lineno&quot;:null}); Total lines: 3868
L0: 【0†Skip to content】
L1: ## Navigation Menu
L2: 
L3: 【1†Sign in】
L4: 
L5: Appearance settings
L6: 
L7:   * Platform
L8: 
L9:     * AI CODE CREATION
L10:       * 【2†GitHub CopilotWrite better code with AI】
L11:       * 【3†GitHub Copilot appDirect agents from issue to merge】
L12:       * 【4†MCP RegistryIntegrate external tools】
L13: 
L14:     * DEVELOPER WORKFLOWS
L15:       * 【5†ActionsAutomate any workflow】
L16:       * 【6†CodespacesInstant dev environments】
L17:       * 【7†IssuesPlan and track work】
L18:       * 【8†Code ReviewManage code changes】
L19:       * 【9†Code QualityEnforce quality at merge】
L20:     * APPLICATION SECURITY
L21:       * 【10†GitHub Advanced SecurityFind and fix vulnerabilities】
L22:       * 【11†Code securitySecure your code as you build】
L23:       * 【12†Secret protectionStop leaks before they start】
L24: 
L25:     * EXPLORE
L26:       * 【13†Why GitHub】
L27:       * 【14†Documentation†docs.github.com】
L28:       * 【15†Blog†github.blog】
L29:       * 【16†Changelog†github.blog】
L30:       * 【17†Marketplace】
L31: 
L32: 【18†View all features】
L33: 
L34:   * Solutions
L35:     * BY COMPANY SIZE
L36:       * 【19†Enterprises】
L37:       * 【20†Small and medium teams】
L38:       * 【21†Startups】
L39:       * 【22†Nonprofits】
L40: 
L41:     * BY USE CASE
L42:       * 【23†App Modernization】
L43:       * 【24†DevSecOps】
L44:       * 【25†DevOps】
L45:       * 【26†CI/CD】
L46:       * 【27†View all use cases】
L47: 
L48:     * BY INDUSTRY
L49:       * 【28†Healthcare】
L50:       * 【29†Financial services】
L51:       * 【30†Manufacturing】
L52:       * 【31†Government】
L53:       * 【32†View all industries】
L54: 
L55: 【33†View all solutions】
L56: 
L57:   * Resources
L58:     * EXPLORE BY TOPIC
L59:       * 【34†AI】
L60:       * 【35†Software Development】
L61:       * 【36†DevOps】
L62:       * 【37†Security】
L63:       * 【38†View all topics】
L64: 
L65:     * EXPLORE BY TYPE
L66:       * 【39†Customer stories】
L67:       * 【40†Events &amp; webinars】
L68:       * 【41†Ebooks &amp; reports】
L69:       * 【42†Business insights】
L70:       * 【43†GitHub Skills†skills.github.com】
L71:     * SUPPORT &amp; SERVICES
L72:       * 【14†Documentation†docs.github.com】
L73:       * 【44†Customer support†support.github.com】
L74:       * 【45†Community forum】
L75:       * 【46†Trust center】
L76:       * 【47†Partners】
L77: 
L78: 【48†View all resources】
L79: 
L80:   * Open Source
L81: 
L82:     * COMMUNITY
L83:       * 【49†GitHub SponsorsFund open source developers】
L84:     * PROGRAMS
L85:       * 【50†Security Lab†securitylab.github.com】
L86:       * 【51†Maintainer Community†maintainers.github.com】
L87:       * 【52†GitHub Stars†stars.github.com】
L88:       * 【53†Archive Program†archiveprogram.github.com】
L89: 
L90:     * REPOSITORIES
L91:       * 【54†Topics】
L92:       * 【55†Trending】
L93:       * 【56†Collections】
L94: 
L95:   * Enterprise
L96: 
L97:     * ENTERPRISE SOLUTIONS
L98:       * 【19†Enterprise platformAI-powered developer platform】
L99:     * AVAILABLE ADD-ONS
L100:       * 【10†GitHub Advanced SecurityEnterprise-grade security features】
L101:       * 【57†Copilot for BusinessEnterprise-grade AI features】
L102:       * 【58†Premium SupportEnterprise-grade 24/7 support】
L103: 
L104:   * 【59†Pricing】
L105: 
L106: Search`/`
L107: 
L108: 【1†Sign in】
L109: 
L110: 【60†Sign up】
L111: 
L112: Appearance settings
L113: You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session. You switched accounts on another tab or window. Reload to refresh your session. Dismiss alert
L114: 
L115:   1. 【61†nodejs】
L116:   2. 【62†node】
L117: ## Repository navigation
L118: 
L119:   *   * 【62†Code】
L120:   * 【63†Issues626 (626)】
L121:   * 【64†Pull requests6…[truncated]</excerpt>
</source>
<source kind="open_page">
<title>node/deps/uv/src/win/process.c at v24.0.0 · nodejs/node · GitHub</title>
<location>https://github.com/nodejs/node/blob/v24.0.0/deps/uv/src/win/process.c</location>
<excerpt>citeturn1view2 [wordlim: 200] Crawled: last week; Content type: text/html; Source: open({&quot;ref_id&quot;:&quot;https://github.com/nodejs/node/blob/v24.0.0/deps/uv/src/win/process.c&quot;,&quot;lineno&quot;:null}); Total lines: 3838
L0: 【0†Skip to content】
L1: ## Navigation Menu
L2: 
L3: 【1†Sign in】
L4: 
L5: Appearance settings
L6: 
L7:   * Platform
L8: 
L9:     * AI CODE CREATION
L10:       * 【2†GitHub CopilotWrite better code with AI】
L11:       * 【3†GitHub Copilot appDirect agents from issue to merge】
L12:       * 【4†MCP RegistryIntegrate external tools】
L13: 
L14:     * DEVELOPER WORKFLOWS
L15:       * 【5†ActionsAutomate any workflow】
L16:       * 【6†CodespacesInstant dev environments】
L17:       * 【7†IssuesPlan and track work】
L18:       * 【8†Code ReviewManage code changes】
L19:       * 【9†Code QualityEnforce quality at merge】
L20:     * APPLICATION SECURITY
L21:       * 【10†GitHub Advanced SecurityFind and fix vulnerabilities】
L22:       * 【11†Code securitySecure your code as you build】
L23:       * 【12†Secret protectionStop leaks before they start】
L24: 
L25:     * EXPLORE
L26:       * 【13†Why GitHub】
L27:       * 【14†Documentation†docs.github.com】
L28:       * 【15†Blog†github.blog】
L29:       * 【16†Changelog†github.blog】
L30:       * 【17†Marketplace】
L31: 
L32: 【18†View all features】
L33: 
L34:   * Solutions
L35:     * BY COMPANY SIZE
L36:       * 【19†Enterprises】
L37:       * 【20†Small and medium teams】
L38:       * 【21†Startups】
L39:       * 【22†Nonprofits】
L40: 
L41:     * BY USE CASE
L42:       * 【23†App Modernization】
L43:       * 【24†DevSecOps】
L44:       * 【25†DevOps】
L45:       * 【26†CI/CD】
L46:       * 【27†View all use cases】
L47: 
L48:     * BY INDUSTRY
L49:       * 【28†Healthcare】
L50:       * 【29†Financial services】
L51:       * 【30†Manufacturing】
L52:       * 【31†Government】
L53:       * 【32†View all industries】
L54: 
L55: 【33†View all solutions】
L56: 
L57:   * Resources
L58:     * EXPLORE BY TOPIC
L59:       * 【34†AI】
L60:       * 【35†Software Development】
L61:       * 【36†DevOps】
L62:       * 【37†Security】
L63:       * 【38†View all topics】
L64: 
L65:     * EXPLORE BY TYPE
L66:       * 【39†Customer stories】
L67:       * 【40†Events &amp; webinars】
L68:       * 【41†Ebooks &amp; reports】
L69:       * 【42†Business insights】
L70:       * 【43†GitHub Skills†skills.github.com】
L71:     * SUPPORT &amp; SERVICES
L72:       * 【14†Documentation†docs.github.com】
L73:       * 【44†Customer support†support.github.com】
L74:       * 【45†Community forum】
L75:       * 【46†Trust center】
L76:       * 【47†Partners】
L77: 
L78: 【48†View all resources】
L79: 
L80:   * Open Source
L81: 
L82:     * COMMUNITY
L83:       * 【49†GitHub SponsorsFund open source developers】
L84:     * PROGRAMS
L85:       * 【50†Security Lab†securitylab.github.com】
L86:       * 【51†Maintainer Community†maintainers.github.com】
L87:       * 【52†GitHub Stars†stars.github.com】
L88:       * 【53†Archive Program†archiveprogram.github.com】
L89: 
L90:     * REPOSITORIES
L91:       * 【54†Topics】
L92:       * 【55†Trending】
L93:       * 【56†Collections】
L94: 
L95:   * Enterprise
L96: 
L97:     * ENTERPRISE SOLUTIONS
L98:       * 【19†Enterprise platformAI-powered developer platform】
L99:     * AVAILABLE ADD-ONS
L100:       * 【10†GitHub Advanced SecurityEnterprise-grade security features】
L101:       * 【57†Copilot for BusinessEnterprise-grade AI features】
L102:       * 【58†Premium SupportEnterprise-grade 24/7 support】
L103: 
L104:   * 【59†Pricing】
L105: 
L106: Search`/`
L107: 
L108: 【1†Sign in】
L109: 
L110: 【60†Sign up】
L111: 
L112: Appearance settings
L113: You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session. You switched accounts on another tab or window. Reload to refresh your session. Dismiss alert
L114: 
L115:   1. 【61†nodejs】
L116:   2. 【62†node】
L117: ## Repository navigation
L118: 
L119:   *   * 【62†Code】
L120:   * 【63†Issues609 (609)】
L121:   * 【64†Pull …[truncated]</excerpt>
</source>
<source kind="search">
<title>Child process | Node.js v26.12.1 Documentation</title>
<location>https://nodejs.org/api/child_process.html</location>
<excerpt>citeturn0search0 [wordlim: 200] Crawled: yesterday; On Windows, environment variables are case-insensitive.Node.js lexicographically sorts the `env` keys and uses the first one that case-insensitively matches.Only first (in lexicographic order) entry will be passed to the subprocess. ... Node.js overwrites `argv[0]` with `process.execPath` on startup, so `process.argv[0]` in a Node.js child process will not match the `argv0` parameter passed to `spawn` from the parent.

On Windows, environment variables are case-insensitive. Node.js lexicographically sorts the `env` keys and uses the first one that case-insensitively matches. Only first (in lexicographic order) entry will be passed to the subprocess. This might lead to issues on Windows when passing objects to the `env` option that have multiple variants of the same key, such as `PATH` and `Path`.

The `child_process.spawn()` method spawns the child process asynchronously, without blocking the Node.js event loop. The `child_process.spawnSync()` function provides equivalent functionality in a synchronous manner that blocks the event loop until the spawned process either exits or is terminated.

For convenience, the `node:child_process` module provides a handful of synchronous and asynchronous alternatives to `child_process.spawn()` and `child_process.spawnSync()`. Each of these alternatives are implemented on top of `child_process.spawn()` or `child_process.spawnSync()`.
</excerpt>
</source>
<omitted_sources count="36">Additional source records were omitted from this bounded result.</omitted_sources>
</source_evidence>

Citations:

- 1: https://github.com/nodejs/node/blob/v24.0.0/lib/child_process.js
- 2: https://github.com/nodejs/node/blob/v24.0.0/lib/child_process.js

🏁 Script executed:

nl -ba src/main/codex-cli/command.test.ts | sed -n '324,435p'
printf '\n--- structured child environment ---\n'
nl -ba src/main/runtime/structured-session-child-env.ts | sed -n '90,125p'
printf '\n--- all pairing call sites ---\n'
rg -n -F -- 'withCliRuntimeOnPath(' src

Repository: stablyai/orca

Length of output: 10420


🏁 Script executed:

rg -n -F -- 'sealStructuredSessionChild(' src
rg -n -F -- 'cliRuntimeCommand' src/main src/shared

Repository: stablyai/orca

Length of output: 1655


Select the Windows PATH key using Node’s spawn ordering.

When both keys exist with different values, firstWindowsPathEnvKey selects Path by insertion order, but Node 24 selects PATH lexicographically. The pairing routine then modifies Path and deletes PATH, so the structured child can lose directories from the effective PATH and fail to find commands. This is limited to duplicate case variants with different values.

Suggested fix
--- "a/src/shared/cli-runtime-pairing-inputs.ts"
+++ "b/src/shared/cli-runtime-pairing-inputs.ts"
@@ -34,7 +34,7 @@
 // Mirrors resolvePathEnvKey in src/main/pty/windows-path-segment-merge.ts, which
 // src/shared must not import.
 export function firstWindowsPathEnvKey(env: NodeJS.ProcessEnv): string {
-  for (const key of Object.keys(env)) {
+  for (const key of Object.keys(env).sort()) {
     if (key.toLowerCase() === 'path' && env[key] !== undefined) {
       return key
     }

Update the duplicate-key test to expect only PATH and to preserve the original PATH value, prefixed with the runtime directory.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const key of Object.keys(env)) {
if (key.toLowerCase() === 'path' && env[key] !== undefined) {
return key
for (const key of Object.keys(env).sort()) {
if (key.toLowerCase() === 'path' && env[key] !== undefined) {
return key

@nwparker
nwparker force-pushed the nwparker/codex-structured-cli-runtime branch from 51837a8 to 35246f5 Compare October 23, 2026 03:58
nwparker and others added 5 commits October 22, 2026 21:02
Pairing ran after the Orca CLI prepend, so a runtime dir that also holds an
`orca` (e.g. /usr/local/bin with node+codex+orca) shadowed this app's
launcher for the child. Pair first; the CLI dir stays first on PATH.
Drops a PATH-shape test the parity snapshots already cover.

Co-Authored-By: Claude <noreply@anthropic.com>
…ring a sibling node

A native codex/claude beside a node (Homebrew, /usr/bin) never runs on it, so
pairing only reordered PATH. An env naming no PATH now pairs over the PATH the
child inherits instead of replacing it with the CLI dir alone.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
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.

1 participant