diff --git a/src/main/index.ts b/src/main/index.ts index 0b46323b514..c1a4624e079 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -62,6 +62,7 @@ import { suppressDevEducationForStore } from './startup/dev-education-suppression' import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' +import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect' import { startFirstWindowStartupServices } from './startup/first-window-startup-services' import { getDevInstanceIdentity } from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' @@ -183,6 +184,18 @@ let firstWindowStartupServicesReady: Promise = Promise.resolve() let localPtyStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 const isServeMode = process.argv.includes('--serve') +// Why: on Windows a CLI-shaped launch (Orca.exe ) that lost +// ELECTRON_RUN_AS_NODE would otherwise boot the GUI, lose the single-instance +// lock to a running window, and exit silently. Redirect it to node mode here, +// before the lock gate below can bounce it. +const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath +}) +if (packagedCliEntryRedirect.redirected) { + app.exit(packagedCliEntryRedirect.status) +} const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, diff --git a/src/main/startup/packaged-cli-entry-redirect.test.ts b/src/main/startup/packaged-cli-entry-redirect.test.ts new file mode 100644 index 00000000000..f892ae6c4da --- /dev/null +++ b/src/main/startup/packaged-cli-entry-redirect.test.ts @@ -0,0 +1,159 @@ +import { win32 } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + getPackagedCliEntryArgs, + maybeRedirectPackagedCliEntryLaunch +} from './packaged-cli-entry-redirect' + +const resourcesPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources' +const execPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\Orca.exe' +const cliEntryPath = win32.join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + +describe('packaged CLI entry redirect', () => { + it('detects Windows GUI launches that received the unpacked CLI entrypoint', () => { + expect( + getPackagedCliEntryArgs( + [execPath, cliEntryPath.toUpperCase(), 'status', '--json'], + cliEntryPath, + 'win32' + ) + ).toEqual(['status', '--json']) + }) + + it('ignores normal desktop launches', () => { + expect(getPackagedCliEntryArgs([execPath, '--updated'], cliEntryPath, 'win32')).toBeNull() + }) + + it('ignores the entrypoint when it is only the executable itself (argv[0])', () => { + expect(getPackagedCliEntryArgs([cliEntryPath, 'status'], cliEntryPath, 'win32')).toBeNull() + }) + + it('does not match the entrypoint on non-Windows platforms', () => { + expect(getPackagedCliEntryArgs([execPath, cliEntryPath, 'status'], cliEntryPath, 'linux')).toBeNull() + }) + + it('spawns the in-package CLI in Electron node mode before the single-instance lock can win', () => { + const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) + + const result = maybeRedirectPackagedCliEntryLaunch({ + argv: [execPath, cliEntryPath, 'status', '--json'], + env: { + NODE_OPTIONS: '--inspect', + NODE_REPL_EXTERNAL_MODULE: 'external-loader' + }, + platform: 'win32', + isPackaged: true, + resourcesPath, + execPath, + exists: () => true, + spawn: spawn as never + }) + + expect(result).toEqual({ redirected: true, status: 0 }) + expect(spawn).toHaveBeenCalledWith( + execPath, + [cliEntryPath, 'status', '--json'], + { + env: expect.objectContaining({ + ELECTRON_RUN_AS_NODE: '1', + ORCA_PACKAGED_CLI_ENTRY_REDIRECTED: '1', + ORCA_NODE_OPTIONS: '--inspect', + ORCA_NODE_REPL_EXTERNAL_MODULE: 'external-loader' + }), + stdio: 'inherit' + } + ) + const spawnOptions = spawn.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv } | undefined + expect(spawnOptions?.env).not.toHaveProperty('NODE_OPTIONS') + expect(spawnOptions?.env).not.toHaveProperty('NODE_REPL_EXTERNAL_MODULE') + }) + + it('never spawns an attacker-supplied script — only the computed in-package entry', () => { + const spawn = vi.fn((..._args: unknown[]) => ({ status: 0 })) + const attackerScript = 'C:\\Users\\me\\evil.js' + + const result = maybeRedirectPackagedCliEntryLaunch({ + // An attacker placing some other script path in argv must not cause it to run. + argv: [execPath, attackerScript, 'status'], + platform: 'win32', + isPackaged: true, + resourcesPath, + execPath, + exists: () => true, + spawn: spawn as never + }) + + expect(result).toEqual({ redirected: false }) + expect(spawn).not.toHaveBeenCalled() + }) + + it('does not redirect development launches', () => { + const spawn = vi.fn() + + const result = maybeRedirectPackagedCliEntryLaunch({ + argv: ['C:\\dev\\Orca.exe', cliEntryPath, 'status'], + platform: 'win32', + isPackaged: false, + resourcesPath, + execPath: 'C:\\dev\\Orca.exe', + exists: () => true, + spawn: spawn as never + }) + + expect(result).toEqual({ redirected: false }) + expect(spawn).not.toHaveBeenCalled() + }) + + it('reports a clear failure instead of locating a missing entrypoint', () => { + const spawn = vi.fn() + const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + try { + const result = maybeRedirectPackagedCliEntryLaunch({ + argv: [execPath, cliEntryPath, 'status'], + platform: 'win32', + isPackaged: true, + resourcesPath, + execPath, + exists: () => false, + spawn: spawn as never + }) + + expect(result).toEqual({ redirected: true, status: 1 }) + expect(stderrWrite).toHaveBeenCalledWith( + `Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n` + ) + expect(spawn).not.toHaveBeenCalled() + } finally { + stderrWrite.mockRestore() + } + }) + + it('fails clearly instead of recursively redirecting when node mode already failed once', () => { + const spawn = vi.fn() + const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + try { + const result = maybeRedirectPackagedCliEntryLaunch({ + argv: [execPath, cliEntryPath, 'status', '--json'], + env: { + ORCA_PACKAGED_CLI_ENTRY_REDIRECTED: '1' + }, + platform: 'win32', + isPackaged: true, + resourcesPath, + execPath, + exists: () => true, + spawn: spawn as never + }) + + expect(result).toEqual({ redirected: true, status: 1 }) + expect(stderrWrite).toHaveBeenCalledWith( + 'Unable to start the Orca CLI through Electron node mode.\n' + ) + expect(spawn).not.toHaveBeenCalled() + } finally { + stderrWrite.mockRestore() + } + }) +}) diff --git a/src/main/startup/packaged-cli-entry-redirect.ts b/src/main/startup/packaged-cli-entry-redirect.ts new file mode 100644 index 00000000000..85445c6a6cd --- /dev/null +++ b/src/main/startup/packaged-cli-entry-redirect.ts @@ -0,0 +1,128 @@ +import { spawnSync, type SpawnSyncReturns } from 'node:child_process' +import { existsSync } from 'node:fs' +import { posix, win32 } from 'node:path' + +type RedirectResult = + | { + redirected: false + } + | { + redirected: true + status: number + } + +type RedirectOptions = { + argv?: string[] + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + isPackaged?: boolean + resourcesPath?: string + execPath?: string + exists?: typeof existsSync + spawn?: typeof spawnSync +} + +// Why: set on the re-spawned node-mode child so a failure to honor +// ELECTRON_RUN_AS_NODE can't make us redirect forever in a tight loop. +const REDIRECT_ATTEMPT_ENV = 'ORCA_PACKAGED_CLI_ENTRY_REDIRECTED' + +/** + * Why: on Windows the bundled `orca.cmd` runs `Orca.exe ` + * with ELECTRON_RUN_AS_NODE=1. When that env var is dropped (e.g. a wrapper or + * shell that resets it), Orca boots as a GUI, loses the single-instance lock to + * an already-running window, and exits silently with no stdout. This detects the + * CLI-shaped launch — argv carrying the known in-package CLI entry path — and + * re-runs it in Electron node mode BEFORE the lock gate, then exits with the + * CLI's status. + * + * Security: the spawned program is always `execPath` (Orca.exe) and the script + * is always `cliEntryPath`, derived solely from `resourcesPath` + a fixed + * relative path — never taken from argv. argv only contributes the trailing + * CLI arguments forwarded to the already-trusted in-package CLI, and the + * redirect only fires when an argv element exactly equals that computed path, + * so it cannot be coerced into spawning an arbitrary script. + */ +export function maybeRedirectPackagedCliEntryLaunch(options: RedirectOptions = {}): RedirectResult { + const argv = options.argv ?? process.argv + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const isPackaged = options.isPackaged ?? false + const resourcesPath = options.resourcesPath ?? process.resourcesPath + const execPath = options.execPath ?? process.execPath + const exists = options.exists ?? existsSync + const spawn = options.spawn ?? spawnSync + const cliEntryPath = buildPackagedCliEntryPath(platform, resourcesPath) + const cliArgs = getPackagedCliEntryArgs(argv, cliEntryPath, platform) + + if (!isPackaged || !cliArgs) { + return { redirected: false } + } + if (env[REDIRECT_ATTEMPT_ENV] === '1') { + process.stderr.write('Unable to start the Orca CLI through Electron node mode.\n') + return { redirected: true, status: 1 } + } + if (!exists(cliEntryPath)) { + process.stderr.write(`Unable to locate the Orca CLI entrypoint at ${cliEntryPath}\n`) + return { redirected: true, status: 1 } + } + + const result = spawn(execPath, [cliEntryPath, ...cliArgs], { + env: buildElectronRunAsNodeEnv(env), + stdio: 'inherit' + }) as SpawnSyncReturns + + if (result.error) { + process.stderr.write(`${result.error.message}\n`) + return { redirected: true, status: 1 } + } + + return { redirected: true, status: result.status ?? 1 } +} + +/** + * Returns the CLI arguments that follow the in-package CLI entrypoint in argv, + * or null when this is not a Windows CLI-shaped launch. Scoped to win32 because + * the AppImage redirect already covers the Linux equivalent. + */ +export function getPackagedCliEntryArgs( + argv: string[], + cliEntryPath: string, + platform: NodeJS.Platform +): string[] | null { + if (platform !== 'win32') { + return null + } + const expectedCliPath = normalizePathForPlatform(cliEntryPath, platform) + const cliEntryIndex = argv.findIndex( + (arg, index) => index > 0 && normalizePathForPlatform(arg, platform) === expectedCliPath + ) + return cliEntryIndex === -1 ? null : argv.slice(cliEntryIndex + 1) +} + +function buildPackagedCliEntryPath(platform: NodeJS.Platform, resourcesPath: string): string { + return getPathApi(platform).join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') +} + +function normalizePathForPlatform(value: string, platform: NodeJS.Platform): string { + const pathApi = getPathApi(platform) + const normalized = pathApi.normalize(pathApi.isAbsolute(value) ? value : pathApi.resolve(value)) + // Why: Windows paths are case-insensitive, so compare case-folded. + return platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function getPathApi(platform: NodeJS.Platform): typeof win32 | typeof posix { + return platform === 'win32' ? win32 : posix +} + +function buildElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const childEnv = { ...env } + // Why: the CLI re-reads these from the ORCA_-prefixed copies; clearing the + // originals keeps Electron's own node bootstrap from inheriting them. + childEnv.ORCA_NODE_OPTIONS = env.NODE_OPTIONS ?? '' + childEnv.ORCA_NODE_REPL_EXTERNAL_MODULE = env.NODE_REPL_EXTERNAL_MODULE ?? '' + childEnv.ELECTRON_RUN_AS_NODE = '1' + childEnv[REDIRECT_ATTEMPT_ENV] = '1' + delete childEnv.NODE_OPTIONS + delete childEnv.NODE_REPL_EXTERNAL_MODULE + return childEnv +}