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',