Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/main/claude/claude-structured-child-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ async function readClaudeChildEnvSources(deps: ClaudeEnvDeps): Promise<ClaudeChi
export function claudeChildEnv(
sources: ClaudeChildEnvSources,
decorateEnv: (env: Record<string, string>) => Record<string, string> = (env) => env,
/** False when the session's seal pairs the CLI runtime itself, after the session's identity. */
/** False when the session's seal pairs the CLI runtime itself, behind this app's CLI. */
{ runtimeOnPath = true }: { runtimeOnPath?: boolean } = {}
): Record<string, string> {
const env = decorateEnv({
Expand Down
6 changes: 5 additions & 1 deletion src/main/claude/claude-structured-launch-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,11 @@ describe('claude structured launch resolution', () => {
})
})({ identity: IDENTITY })

expect((launch.env?.PATH ?? launch.env?.Path)?.split(delimiter)[0]).toBe(binDir)
// Right behind this app's CLI, so a runtime dir holding another `orca` cannot shadow it.
expect((launch.env?.PATH ?? launch.env?.Path)?.split(delimiter).slice(1)).toEqual([
binDir,
'/usr/bin'
])
})

it('refuses other hosts, WSL, providers, and account-home variables', async () => {
Expand Down
27 changes: 27 additions & 0 deletions src/main/codex-cli/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,33 @@ describe('withCliRuntimeOnPath', () => {
expect(withCliRuntimeOnPath(join(brew, 'codex'), env, { platform: 'darwin' })).toBe(env)
})

it('leaves PATH untouched for a compiled CLI beside a node (Homebrew, /usr/bin)', () => {
const root = makeCommandFixture('orca-pair-')
const brew = join(root, 'opt', 'homebrew', 'bin')
makeExecutable(join(brew, 'node'))
makeExecutable(join(brew, 'codex'))
writeFileSync(join(brew, 'codex'), Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x07]))
const env = { PATH: '/usr/bin' }

expect(withCliRuntimeOnPath(join(brew, 'codex'), env, { platform: 'darwin' })).toBe(env)
})

it('pairs over the inherited PATH when the env names none, instead of replacing it', () => {
const root = makeCommandFixture('orca-pair-')
const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin')
makeExecutable(join(v20, 'node'))
makeExecutable(join(v20, 'codex'))
const inherited = process.env.PATH
onTestFinished(() => {
process.env.PATH = inherited
})
process.env.PATH = ['/opt/inherited/bin', '/usr/bin'].join(delimiter)

const noPath: NodeJS.ProcessEnv = {}
const paired = withCliRuntimeOnPath(join(v20, 'codex'), noPath, { platform: 'darwin' })
expect(paired.PATH).toBe([v20, '/opt/inherited/bin', '/usr/bin'].join(delimiter))
})

it('leaves PATH untouched for a bare command name', () => {
const env = { PATH: '/usr/bin' }
expect(withCliRuntimeOnPath('codex', env, { platform: 'darwin' })).toBe(env)
Expand Down
44 changes: 42 additions & 2 deletions src/main/codex/codex-structured-child-environment.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env'
import { sealStructuredSessionChild } from '../runtime/structured-session-child-env'
Expand All @@ -20,7 +23,8 @@ const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/
/** The sealed env a Codex launch of `sessionId` spawns with, over a shell env of `env`. */
async function codexChildEnv(
env: Record<string, string>,
sessionId: string
sessionId: string,
command = 'codex'
): Promise<Record<string, string>> {
const record = agentSessionRecordFixture({
sessionId,
Expand All @@ -30,7 +34,7 @@ async function codexChildEnv(
const launch = await createCodexStructuredLaunchResolver({
store: { getRecord: () => record, pinLaunchDirectory: vi.fn() },
resolveEnvironment: async () => env,
resolveCommand: () => 'codex',
resolveCommand: () => command,
resolveLaunchArgs: () => []
})({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a launch reads only the session id of its identity here.
Expand Down Expand Up @@ -151,6 +155,42 @@ describe('the spawned Codex child', () => {
vi.unstubAllEnvs()
})

it.skipIf(process.platform === 'win32')(
'starts the persistent shebang CLI with its sibling Node over a competing PATH runtime',
async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-shebang-'))
try {
const selectedBin = join(root, 'selected', 'bin')
const competingBin = join(root, 'other', 'bin')
mkdirSync(selectedBin, { recursive: true })
mkdirSync(competingBin, { recursive: true })
symlinkSync(process.execPath, join(selectedBin, 'node'))
writeFileSync(join(competingBin, 'node'), '#!/bin/sh\nexit 79\n', { mode: 0o755 })
const command = join(selectedBin, 'codex')
writeFileSync(command, `#!/usr/bin/env node\n${ENV_REPORTING_APP_SERVER}`, { mode: 0o755 })
const env = await codexChildEnv({ PATH: competingBin }, 'shebang-session', command)
const connection = await openCodexAppServerConnection({
command,
args: ['app-server'],
cwd: root,
env
})
try {
await expect(connection.request('test/env')).resolves.toMatchObject({
sessionId: 'shebang-session',
path: expect.stringMatching(DEV_CLI_BIN_FIRST),
cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER)
})
expect(env.PATH.split(delimiter).slice(1)).toEqual([selectedBin, competingBin])
} finally {
await connection.close()
}
} finally {
rmSync(root, { recursive: true, force: true })
}
}
)

it("runs with its own session id and this app's CLI, over an id inherited by Orca itself", async () => {
// The builder's output is an overlay on process.env, so only the spawned child proves the id
// survives the merge — an Orca launched inside another session inherits that session's id.
Expand Down
3 changes: 2 additions & 1 deletion src/main/codex/codex-structured-launch-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ export function codexStructuredLaunchPart(
inheritedEnvToDelete: [
...(visuals ? [] : [NATIVE_CHAT_SCRATCH_DIR_ENV]),
LEGACY_NATIVE_CHAT_VISUALS_DIR_ENV
]
],
cliRuntimeCommand: command
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ exports[`native-chat launch parity > claude floating fresh 1`] = `
"ORCA_USER_DATA_PATH": "<root>/user-data",
"ORCA_WORKTREE_ID": "parent-worktree",
"PARITY_OVERLAY": "claude",
"PATH": "<root>/bin:<root>/orca-cli/bin:/opt/login-shell/bin:/usr/bin:/bin",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
"SHELL_UNLISTED": "shell",
},
Expand Down Expand Up @@ -652,7 +652,7 @@ exports[`native-chat launch parity > claude floating resumed 1`] = `
"ORCA_USER_DATA_PATH": "<root>/user-data",
"ORCA_WORKTREE_ID": "parent-worktree",
"PARITY_OVERLAY": "claude",
"PATH": "<root>/bin:<root>/orca-cli/bin:/opt/login-shell/bin:/usr/bin:/bin",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
"SHELL_UNLISTED": "shell",
},
Expand Down Expand Up @@ -738,7 +738,7 @@ exports[`native-chat launch parity > claude worktree fresh 1`] = `
"ORCA_USER_DATA_PATH": "<root>/user-data",
"ORCA_WORKTREE_ID": "parent-worktree",
"PARITY_OVERLAY": "claude",
"PATH": "<root>/bin:<root>/orca-cli/bin:/opt/login-shell/bin:/usr/bin:/bin",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
"SHELL_UNLISTED": "shell",
},
Expand Down Expand Up @@ -904,7 +904,7 @@ exports[`native-chat launch parity > claude worktree fresh worker 1`] = `
"ORCA_USER_DATA_PATH": "<root>/user-data",
"ORCA_WORKTREE_ID": "parent-worktree",
"PARITY_OVERLAY": "claude",
"PATH": "<root>/bin:<root>/orca-cli/bin:/opt/login-shell/bin:/usr/bin:/bin",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
"SHELL_UNLISTED": "shell",
},
Expand Down Expand Up @@ -990,7 +990,7 @@ exports[`native-chat launch parity > claude worktree resumed 1`] = `
"ORCA_USER_DATA_PATH": "<root>/user-data",
"ORCA_WORKTREE_ID": "parent-worktree",
"PARITY_OVERLAY": "claude",
"PATH": "<root>/bin:<root>/orca-cli/bin:/opt/login-shell/bin:/usr/bin:/bin",
"PATH": "<root>/orca-cli/bin:<root>/bin:/opt/login-shell/bin:/usr/bin:/bin",
"SHELL_ONLY": "shell",
"SHELL_UNLISTED": "shell",
},
Expand Down
13 changes: 7 additions & 6 deletions src/main/runtime/structured-session-child-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export type SealedStructuredSessionChild = {
* `env` named as this session's child. `inheritedEnvToDelete` is required so every agent states
* what it strips, none included; a key the seal itself names the child by is never stripped (a
* registered worker keeps its handle). `cliRuntimeCommand`: a Node CLI whose own directory goes
* first on PATH, after the identity's, so it runs on the runtime beside it.
* on PATH right behind this app's CLI, so it runs on the runtime beside it.
*/
export function sealStructuredSessionChild(input: {
sessionId: string
Expand All @@ -106,13 +106,14 @@ export function sealStructuredSessionChild(input: {
inheritedEnvToDelete: readonly string[]
cliRuntimeCommand?: string
}): SealedStructuredSessionChild {
const identity = childIdentity(input.sessionId, input.env)
// Why: pair before naming the child so this app's CLI dir stays ahead of a runtime dir holding another `orca`.
const paired = input.cliRuntimeCommand
? withCliRuntimeOnPath(input.cliRuntimeCommand, input.env, { platform: process.platform })
: input.env
const identity = childIdentity(input.sessionId, paired)
const named = new Set([...Object.keys(identity.commandEnv), ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV])
const env = input.cliRuntimeCommand
? withCliRuntimeOnPath(input.cliRuntimeCommand, identity.env, { platform: process.platform })
: identity.env
return {
env: { ...env, [ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: input.spawnToken },
env: { ...identity.env, [ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: input.spawnToken },
envToDelete: input.inheritedEnvToDelete.filter((key) => !named.has(key)),
commandEnv: identity.commandEnv
}
Expand Down
43 changes: 43 additions & 0 deletions src/shared/cli-runtime-pairing-inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { closeSync, openSync, readSync } from 'node:fs'

const NATIVE_EXECUTABLE_MAGIC = [
[0x7f, 0x45, 0x4c, 0x46], // ELF
[0xcf, 0xfa, 0xed, 0xfe], // Mach-O 64-bit
[0xce, 0xfa, 0xed, 0xfe], // Mach-O 32-bit
[0xca, 0xfe, 0xba, 0xbe], // Mach-O universal
[0x4d, 0x5a] // Windows PE
]

/** A compiled binary runs on no `node`, so pairing would only reorder PATH (Homebrew, /usr/bin). */
export function isNativeExecutable(commandPath: string): boolean {
let fd: number | null = null
try {
fd = openSync(commandPath, 'r')
const head = Buffer.alloc(4)
const read = readSync(fd, head, 0, head.length, 0)
return NATIVE_EXECUTABLE_MAGIC.some(
(magic) => read >= magic.length && magic.every((byte, index) => head[index] === byte)
)
} catch {
return false
} finally {
if (fd !== null) {
closeSync(fd)
}
}
}

// Why: Win32 resolves env names case-insensitively and object order preserves
// the block order, so the entry the child will actually read is the FIRST
// case-insensitive match — not necessarily `Path` or `PATH`. Reading a narrower
// set than withCliRuntimeOnPath's twin dedupe deletes would destroy a third spelling unread.
// 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)) {
if (key.toLowerCase() === 'path' && env[key] !== undefined) {
return key
Comment on lines +37 to +39

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

}
}
return 'Path'
}
28 changes: 9 additions & 19 deletions src/shared/node-cli-command-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { delimiter, dirname, isAbsolute, join } from 'node:path'
import { firstWindowsPathEnvKey, isNativeExecutable } from './cli-runtime-pairing-inputs'
import { getSystemCliInstallDirectories } from './system-cli-install-dirs'

type ResolveCommandOptions = {
Expand Down Expand Up @@ -329,21 +330,6 @@ export function resolveClaudeCommand(options: ResolveCommandOptions = {}): strin
return resolveCliCommand('claude', options)
}

// Why: Win32 resolves env names case-insensitively and object order preserves
// the block order, so the entry the child will actually read is the FIRST
// case-insensitive match — not necessarily `Path` or `PATH`. Reading a narrower
// set than the dedupe below deletes would destroy a third spelling unread.
// Mirrors resolvePathEnvKey in src/main/pty/windows-path-segment-merge.ts, which
// src/shared must not import.
function firstWindowsPathEnvKey(env: NodeJS.ProcessEnv): string {
for (const key of Object.keys(env)) {
if (key.toLowerCase() === 'path' && env[key] !== undefined) {
return key
}
}
return 'Path'
}

/**
* Put a resolved CLI's own directory ahead of PATH when that directory ships a
* sibling `node`.
Expand All @@ -355,8 +341,8 @@ function firstWindowsPathEnvKey(env: NodeJS.ProcessEnv): string {
* require (stablyai/orca#10932). Pair the binary with the runtime it was
* installed against instead.
*
* Only prepends when the sibling `node` really exists, so a CLI resolved from a
* directory that ships no node is left alone.
* Only prepends when the sibling `node` really exists and the CLI is not a
* compiled binary, so a native CLI or one beside no node is left alone.
*/
export function withCliRuntimeOnPath<T extends NodeJS.ProcessEnv>(
commandPath: string,
Expand All @@ -368,12 +354,16 @@ export function withCliRuntimeOnPath<T extends NodeJS.ProcessEnv>(
return env
}
const commandDirectory = dirname(commandPath)
if (!findFirstExecutable(platform, [commandDirectory], getExecutableNames(platform, 'node'))) {
if (
!findFirstExecutable(platform, [commandDirectory], getExecutableNames(platform, 'node')) ||
isNativeExecutable(commandPath)
) {
return env
}
const pathKey = platform === 'win32' ? firstWindowsPathEnvKey(env) : 'PATH'
const pathDelimiter = platform === 'win32' ? ';' : delimiter
const segments = splitPath(env[pathKey], pathDelimiter)
// Why: the child inherits Orca's PATH when `env` names none, so pair over that, not over nothing.
const segments = splitPath(env[pathKey] ?? process.env[pathKey], pathDelimiter)
if (segments[0] === commandDirectory) {
return env
}
Expand Down
Loading