From 5c4ed35a0cb9777b3042f60564546fc830dbcf3a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:21:20 -0700 Subject: [PATCH] fix(codex): resolve the launch preflight to a verified absolute Orca CLI path (STA-4270) The Codex launch preflight carried a bare command name ('orca' / 'orca-dev') in ORCA_CODEX_LAUNCH_PREFLIGHT. The codex() wrapper that invokes it is emitted after the user's profile scripts are sourced, and those routinely rewrite PATH, so the name was resolved against a PATH Orca neither controls nor can predict. Resolve and verify the shipped CLI's absolute path instead, and return null when no path verifies so the preflight is skipped rather than run against an unidentified program. --- src/main/cli/bundled-cli-launcher-path.ts | 23 ++ src/main/cli/cli-installer.ts | 22 +- src/main/ipc/pty.test.ts | 76 ++++++- src/main/ipc/pty.ts | 4 +- ...dex-preflight-profile-path-rewrite.test.ts | 127 +++++++++++ .../pty/codex-shell-launch-preflight.test.ts | 202 +++++++++++++++++- src/main/pty/codex-shell-launch-preflight.ts | 54 ++++- src/main/pty/wsl-orca-env.test.ts | 2 +- 8 files changed, 477 insertions(+), 33 deletions(-) create mode 100644 src/main/cli/bundled-cli-launcher-path.ts create mode 100644 src/main/pty/codex-preflight-profile-path-rewrite.test.ts diff --git a/src/main/cli/bundled-cli-launcher-path.ts b/src/main/cli/bundled-cli-launcher-path.ts new file mode 100644 index 00000000000..1271198b946 --- /dev/null +++ b/src/main/cli/bundled-cli-launcher-path.ts @@ -0,0 +1,23 @@ +import { join } from 'node:path' + +// Why `orca-ide` on Linux: GNOME Orca ships /usr/bin/orca, so the CLI never claims that name. +export const LINUX_CLI_COMMAND_NAME = 'orca-ide' + +/** Absolute path of the CLI launcher this app ships in its own resources bundle. + * Lives apart from cli-installer so callers that only need the path (PTY env + * assembly) don't pull in the installer's `electron` dependency. */ +export function getBundledLauncherPath( + platform: NodeJS.Platform, + resourcesPath: string +): string | null { + if (platform === 'darwin') { + return join(resourcesPath, 'bin', 'orca') + } + if (platform === 'linux') { + return join(resourcesPath, 'bin', LINUX_CLI_COMMAND_NAME) + } + if (platform === 'win32') { + return join(resourcesPath, 'bin', 'orca.exe') + } + return null +} diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 74c8dbcfb77..a57fdf95924 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -19,6 +19,7 @@ import { promisify } from 'node:util' import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { invalidateWindowsUserPathRegistryCache, readFreshWindowsUserPathRegistry, @@ -29,7 +30,6 @@ import { const execFileAsync = promisify(execFile) const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca' const DEV_COMMAND_NAME = 'orca-dev' -const LINUX_COMMAND_NAME = 'orca-ide' const LEGACY_LINUX_COMMAND_NAME = 'orca' const DEV_LAUNCHER_DIR = ['cli', 'bin'] const WINDOWS_PATH_WRITE_TIMEOUT_MS = 5_000 @@ -88,7 +88,7 @@ export class CliInstaller { return DEV_COMMAND_NAME } // Why: packaged Linux uses `orca-ide` to avoid shadowing GNOME Orca's /usr/bin/orca. - return this.platform === 'linux' ? LINUX_COMMAND_NAME : 'orca' + return this.platform === 'linux' ? LINUX_CLI_COMMAND_NAME : 'orca' } constructor(options: CliInstallerOptions = {}) { @@ -351,7 +351,7 @@ export class CliInstaller { if (this.platform === 'linux') { // Why: Linux lacks a privileged global command flow; ~/.local/bin is the least-surprising user-scoped dir. // Why `orca-ide`: GNOME Orca ships /usr/bin/orca, so avoid shadowing that screen reader. - return join(this.homePath, '.local', 'bin', LINUX_COMMAND_NAME) + return join(this.homePath, '.local', 'bin', LINUX_CLI_COMMAND_NAME) } if (this.platform === 'win32') { @@ -1200,18 +1200,4 @@ function quotePowerShell(value: string): string { return `'${value.replaceAll("'", "''")}'` } -export function getBundledLauncherPath( - platform: NodeJS.Platform, - resourcesPath: string -): string | null { - if (platform === 'darwin') { - return join(resourcesPath, 'bin', 'orca') - } - if (platform === 'linux') { - return join(resourcesPath, 'bin', LINUX_COMMAND_NAME) - } - if (platform === 'win32') { - return join(resourcesPath, 'bin', 'orca.exe') - } - return null -} +export { getBundledLauncherPath } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 88306e95a0d..b7b9559e2e1 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -17,9 +17,12 @@ import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-h import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' import type * as Wsl from '../wsl' +import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it +const BUNDLED_RESOURCES_PATH = join('/tmp', 'orca-bundled-resources') +const BUNDLED_CLI_PATH = getBundledLauncherPath(process.platform, BUNDLED_RESOURCES_PATH) as string // Why: bare shells no longer mkdir ~/.omp; OMP status lives under userData (#10196). const expectedOmpStatusExtension = posix.join( '/tmp/orca-user-data', @@ -133,7 +136,8 @@ vi.mock('fs', () => ({ writeFileSync: writeFileSyncMock, chmodSync: chmodSyncMock, constants: { - X_OK: 1 + X_OK: 1, + R_OK: 4 } })) @@ -1939,6 +1943,42 @@ describe('registerPtyHandlers', () => { ] } + // Why: the Codex launch preflight now carries the bundled CLI's verified absolute + // path, so these cases need a resources root whose launcher passes the exec check. + async function withBundledCli( + run: () => Promise, + options?: { launcherExecutable?: boolean } + ): Promise { + const launcherExecutable = options?.launcherExecutable ?? true + const previousResourcesPath = process.resourcesPath + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: BUNDLED_RESOURCES_PATH + }) + // Why: only teach the launcher path to look like an executable file; every other + // stat/access keeps the permissive default the rest of the harness relies on. + statSyncMock.mockImplementation((target: string) => ({ + isDirectory: () => target !== BUNDLED_CLI_PATH, + isFile: () => target === BUNDLED_CLI_PATH, + mode: 0o755 + })) + if (!launcherExecutable) { + accessSyncMock.mockImplementation((target: string) => { + if (target === BUNDLED_CLI_PATH) { + throw new Error(`EACCES: ${target}`) + } + }) + } + try { + return await run() + } finally { + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: previousResourcesPath + }) + } + } + describe('spawn environment', () => { it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => { const originalPlatform = process.platform @@ -2133,10 +2173,40 @@ describe('registerPtyHandlers', () => { }) it('injects the selected Codex home into Orca terminal PTYs', async () => { - const env = await spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME) + const env = await withBundledCli(() => + spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME) + ) expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME) - expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe('orca') + // Why (STA-4270): a bare name would be resolved by the post-profile PATH the codex() + // wrapper inherits, so the preflight must carry the CLI's verified absolute path. + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH) + }) + + it('skips the Codex launch preflight when the bundled CLI is not executable', async () => { + const env = await withBundledCli( + () => spawnAndGetEnv(undefined, undefined, () => TEST_CODEX_HOME), + { launcherExecutable: false } + ) + + expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME) + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBeUndefined() + }) + + // Why (STA-4270): profile scripts run before the codex() wrapper and routinely prepend + // directories to PATH, so a scratch `orca` there must never become the preflight. + it('pins the Codex launch preflight to the bundled CLI even when PATH leads elsewhere', async () => { + const env = await withBundledCli(() => + spawnAndGetEnv( + { PATH: `/tmp/hijack-scratch${delimiter}/usr/bin` }, + undefined, + () => TEST_CODEX_HOME + ) + ) + + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).toBe(BUNDLED_CLI_PATH) + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT).not.toBe('orca') + expect(env.ORCA_CODEX_LAUNCH_PREFLIGHT.startsWith('/tmp/hijack-scratch')).toBe(false) }) it('does not install the Codex launch preflight when Codex hooks are disabled', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 6e28044a889..8cf72e2e4a7 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1863,7 +1863,9 @@ export function buildPtyHostEnv( hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, isPackaged: opts.isPackaged, isWsl: opts.isWsl, - managedHomePath: opts.selectedCodexHomePath + managedHomePath: opts.selectedCodexHomePath, + userDataPath: opts.userDataPath, + resourcesPath: opts.resourcesPath }) if (preflightCommand) { baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand diff --git a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts new file mode 100644 index 00000000000..ce7e8c4b6ee --- /dev/null +++ b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts @@ -0,0 +1,127 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path' +import { getDaemonBashShellReadyRcfileContent } from '../daemon/shell-ready' +import { resolveCodexShellLaunchPreflightCommand } from './codex-shell-launch-preflight' + +// Why (STA-4270): the generated rcfile sources /etc/profile and the user's profile +// *before* it defines the codex() wrapper, so a profile PATH prepend decides what an +// unqualified preflight command resolves to. This drives the real generated rcfile +// through a real bash and uses filesystem markers — not scraped output — as the oracle. + +const roots: string[] = [] +const bashAvailable = process.platform !== 'win32' && existsSync('/bin/bash') + +type Fixture = { + root: string + resourcesPath: string + intendedMarker: string + hijackMarker: string + codexMarker: string + homePath: string +} + +function writeStub(path: string, markerPath: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, `#!/bin/sh\nprintf '%s\\n' "$*" > ${JSON.stringify(markerPath)}\n`) + chmodSync(path, 0o755) +} + +function buildFixture(): Fixture { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-profile-path-')) + roots.push(root) + const resourcesPath = join(root, 'resources') + const hijackDir = join(root, 'hijack') + const codexDir = join(root, 'codex-bin') + const homePath = join(root, 'home') + const intendedMarker = join(root, 'intended-ran') + const hijackMarker = join(root, 'hijack-ran') + const codexMarker = join(root, 'codex-ran') + mkdirSync(hijackDir, { recursive: true }) + mkdirSync(codexDir, { recursive: true }) + mkdirSync(homePath, { recursive: true }) + + // The CLI Orca ships, at the absolute path Orca controls. + writeStub(getBundledLauncherPath(process.platform, resourcesPath) as string, intendedMarker) + // The impostor a user's own bin directory could hold under every CLI name Orca uses. + for (const name of ['orca', 'orca-ide', 'orca-dev']) { + writeStub(join(hijackDir, name), hijackMarker) + } + writeStub(join(codexDir, 'codex'), codexMarker) + + // Why: /etc/profile runs first and macOS path_helper rebuilds PATH from /etc/paths, + // so the profile must re-prepend both dirs to model a real user's dotfile winning. + writeFileSync( + join(homePath, '.bash_profile'), + `export PATH=${JSON.stringify(codexDir)}:"$PATH"\nexport PATH=${JSON.stringify(hijackDir)}:"$PATH"\n` + ) + return { root, resourcesPath, intendedMarker, hijackMarker, codexMarker, homePath } +} + +function launchCodexThroughRcfile(fixture: Fixture, preflightValue: string): void { + const rcfilePath = join(fixture.root, 'rcfile') + writeFileSync(rcfilePath, getDaemonBashShellReadyRcfileContent(), 'utf8') + execFileSync('/bin/bash', ['--rcfile', rcfilePath, '-i', '-c', 'codex --version'], { + stdio: 'ignore', + env: { + HOME: fixture.homePath, + // Why: the hijack dir is deliberately absent here — only the profile adds it. + PATH: ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(delimiter), + TERM: 'dumb', + SHELL: '/bin/bash', + ORCA_SHELL_READY_MARKER: '0', + ORCA_SHELL_STARTUP_IDENTITY: '0', + ORCA_CODEX_HOME: join(fixture.root, 'codex-home'), + ORCA_CODEX_LAUNCH_PREFLIGHT: preflightValue + } + }) +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH', () => { + it('sources the user profile before emitting the codex wrapper', () => { + const rcfile = getDaemonBashShellReadyRcfileContent() + + expect(rcfile.indexOf('source "$HOME/.bash_profile"')).toBeLessThan( + rcfile.indexOf('ORCA_CODEX_LAUNCH_PREFLIGHT') + ) + }) + + it('runs the bundled CLI the resolver picked, not the impostor the profile put first', () => { + const fixture = buildFixture() + const preflightCommand = resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: join(fixture.root, 'codex-home'), + userDataPath: join(fixture.root, 'user-data'), + resourcesPath: fixture.resourcesPath + }) + expect(preflightCommand).toBe(getBundledLauncherPath(process.platform, fixture.resourcesPath)) + + launchCodexThroughRcfile(fixture, preflightCommand as string) + + expect(existsSync(fixture.intendedMarker)).toBe(true) + expect(existsSync(fixture.hijackMarker)).toBe(false) + // Guard against a vacuous pass: the wrapper must still have reached `command codex`. + expect(existsSync(fixture.codexMarker)).toBe(true) + }) + + // Why: pins the defect itself, so a regression back to an unqualified name fails here. + it('would run the impostor if the preflight carried an unqualified command name', () => { + const fixture = buildFixture() + + launchCodexThroughRcfile(fixture, 'orca') + + expect(existsSync(fixture.hijackMarker)).toBe(true) + expect(existsSync(fixture.intendedMarker)).toBe(false) + expect(existsSync(fixture.codexMarker)).toBe(true) + }) +}) diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index 52de4839701..e642c03dd77 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -1,6 +1,6 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' +import { delimiter, isAbsolute, join } from 'node:path' import { execFileSync, spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' import { @@ -198,21 +198,112 @@ describe('PowerShell Codex shell launch preflight', () => { }) describe('Codex shell launch preflight command', () => { - it('uses the packaged or development CLI only for native managed homes', () => { + function makeCliRoot(): { root: string; userDataPath: string; resourcesPath: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-preflight-cli-')) + roots.push(root) + const userDataPath = join(root, 'user-data') + const resourcesPath = join(root, 'resources') + mkdirSync(join(userDataPath, 'cli', 'bin'), { recursive: true }) + mkdirSync(join(resourcesPath, 'bin'), { recursive: true }) + return { root, userDataPath, resourcesPath } + } + + it.each([ + { platform: 'darwin' as const, bundled: 'orca' }, + { platform: 'linux' as const, bundled: 'orca-ide' }, + { platform: 'win32' as const, bundled: 'orca.exe' } + ])('carries the verified bundled $platform launcher as an absolute path', (config) => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', config.bundled) + writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') + expect( resolveCodexShellLaunchPreflightCommand({ hooksEnabled: true, isPackaged: true, - managedHomePath: '/managed/home' + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: config.platform }) - ).toBe('orca') + ).toBe(launcherPath) + }) + + it('carries the verified dev launcher as an absolute path', () => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(userDataPath, 'cli', 'bin', 'orca-dev') + writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') + expect( resolveCodexShellLaunchPreflightCommand({ hooksEnabled: true, isPackaged: false, - managedHomePath: '/managed/home' + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: 'darwin' }) - ).toBe('orca-dev') + ).toBe(launcherPath) + }) + + it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => { + const { userDataPath, resourcesPath } = makeCliRoot() + writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') + writeExecutable(join(userDataPath, 'cli', 'bin', 'orca-dev'), '#!/bin/sh\nexit 0\n') + + for (const isPackaged of [true, false]) { + const command = resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged, + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: 'darwin' + }) + expect(command).not.toBeNull() + expect(isAbsolute(command as string)).toBe(true) + } + }) + + it.each([ + { label: 'the launcher file is missing', create: null }, + { label: 'the launcher is not executable', create: 0o644 }, + { label: 'the launcher path is a directory', create: 'directory' as const } + ])('skips the preflight when $label', (config) => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', 'orca') + if (config.create === 'directory') { + mkdirSync(launcherPath) + } else if (config.create !== null) { + writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n') + chmodSync(launcherPath, config.create) + } + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: 'darwin' + }) + ).toBeNull() + }) + + it('skips the preflight when the packaged build exposes no resources root', () => { + const { userDataPath } = makeCliRoot() + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: '/managed/home', + userDataPath, + resourcesPath: null, + platform: 'darwin' + }) + ).toBeNull() }) it.each([ @@ -220,6 +311,103 @@ describe('Codex shell launch preflight command', () => { { hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home' }, { hooksEnabled: true, isWsl: false, managedHomePath: null } ])('does not enable an unsupported preflight for %o', (options) => { - expect(resolveCodexShellLaunchPreflightCommand({ ...options, isPackaged: true })).toBeNull() + const { userDataPath, resourcesPath } = makeCliRoot() + writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') + + expect( + resolveCodexShellLaunchPreflightCommand({ + ...options, + isPackaged: true, + userDataPath, + resourcesPath, + platform: 'darwin' + }) + ).toBeNull() }) }) + +// Why: the resolved value is now an absolute path, and app bundles (macOS) and +// Program Files (Windows) both put spaces in it. +describe.skipIf(process.platform === 'win32')('Codex preflight paths containing spaces', () => { + function writeSpacedPreflight(root: string): { preflightPath: string; markerPath: string } { + const dir = join(root, 'Orca Dev.app', 'Contents', 'Resources', 'bin') + mkdirSync(dir, { recursive: true }) + const markerPath = join(root, 'preflight-ran') + const preflightPath = join(dir, 'orca') + writeExecutable( + preflightPath, + `#!/bin/sh\n[ "$1 $2 $3" = "agent hooks prepare-codex" ] || exit 2\nprintf ran > ${JSON.stringify(markerPath)}\n` + ) + return { preflightPath, markerPath } + } + + it('invokes a POSIX preflight whose absolute path contains spaces', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-posix-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + writeExecutable(join(bin, 'codex'), '#!/bin/sh\nexit 0\n') + const { preflightPath, markerPath } = writeSpacedPreflight(root) + + execFileSync( + '/bin/bash', + ['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`], + { + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, + ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath + } + } + ) + + expect(existsSync(markerPath)).toBe(true) + }) + + it.skipIf(!fishAvailable)('invokes a fish preflight whose absolute path contains spaces', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-fish-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + writeExecutable(join(bin, 'codex'), '#!/bin/sh\nexit 0\n') + const { preflightPath, markerPath } = writeSpacedPreflight(root) + + execFileSync('fish', ['--no-config', '-c', `${getFishCodexShellLaunchPreflight()}\ncodex`], { + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, + ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath + } + }) + + expect(existsSync(markerPath)).toBe(true) + }) + + it.skipIf(!pwshAvailable)( + 'invokes a PowerShell preflight whose absolute path contains spaces', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-spaced-pwsh-')) + roots.push(root) + const bin = join(root, 'bin') + mkdirSync(bin) + writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n') + const { preflightPath, markerPath } = writeSpacedPreflight(root) + + const result = spawnSync( + 'pwsh', + ['-NoLogo', '-NoProfile', '-Command', `${getPowerShellCodexShellLaunchPreflight()}\ncodex`], + { + encoding: 'utf-8', + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, + ORCA_CODEX_LAUNCH_PREFLIGHT: preflightPath + } + } + ) + + expect(result.status, result.stderr).toBe(0) + expect(existsSync(markerPath)).toBe(true) + } + ) +}) diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 049ddda72af..9242eba0559 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -1,13 +1,61 @@ -export function resolveCodexShellLaunchPreflightCommand(options: { +import { accessSync, constants, statSync } from 'node:fs' +import { join } from 'node:path' +import { getBundledLauncherPath } from '../cli/bundled-cli-launcher-path' + +const DEV_LAUNCHER_DIR = ['cli', 'bin'] +const DEV_COMMAND_NAME = 'orca-dev' + +export type CodexShellLaunchPreflightCommandOptions = { hooksEnabled: boolean isPackaged: boolean isWsl?: boolean managedHomePath: string | null -}): string | null { + /** Where the dev launcher is written; `join(userDataPath, 'cli', 'bin')` is also what managed dev PTYs prepend to PATH. */ + userDataPath: string + /** Packaged app resources root; the bundled launcher lives under it. */ + resourcesPath?: string | null + /** Test seam. */ + platform?: NodeJS.Platform +} + +/** Absolute path of the Orca CLI the preflight must execute, or null to skip it. + * + * Why absolute: the value rides in ORCA_CODEX_LAUNCH_PREFLIGHT and is invoked + * from the codex() wrapper, which shell-ready emits *after* the user's profile + * scripts run. Those scripts routinely rewrite PATH, so an unqualified name + * would be resolved against a PATH Orca neither controls nor can predict — + * handing Orca's managed Codex environment to an unidentified program. When no + * path verifies, skipping the preflight is the predictable degradation. */ +export function resolveCodexShellLaunchPreflightCommand( + options: CodexShellLaunchPreflightCommandOptions +): string | null { if (!options.hooksEnabled || options.isWsl || !options.managedHomePath) { return null } - return options.isPackaged ? 'orca' : 'orca-dev' + const platform = options.platform ?? process.platform + const candidate = options.isPackaged + ? options.resourcesPath + ? getBundledLauncherPath(platform, options.resourcesPath) + : null + : join( + options.userDataPath, + ...DEV_LAUNCHER_DIR, + platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME + ) + return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null +} + +function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(path).isFile()) { + return false + } + // Why: Windows has no exec bit, so a readable launcher file is the strongest signal available. + accessSync(path, platform === 'win32' ? constants.R_OK : constants.X_OK) + return true + } catch { + return false + } } export function getPosixCodexShellLaunchPreflight(): string { diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index a62e043c1f7..916d11dfd67 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -47,7 +47,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_TERMINAL_HANDLE: 'term_wsl', ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_CLI_COMMAND: 'orca-ide', - ORCA_CODEX_LAUNCH_PREFLIGHT: 'orca', + ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PRIME_AGENT_STATUS_EXTENSION: 'C:\\stale\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1',