mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Run the packaged orca CLI even when an Orca GUI instance holds the single-instance lock (#6542)
On Windows the bundled orca.cmd runs Orca.exe with the unpacked CLI entrypoint and ELECTRON_RUN_AS_NODE=1. When that env var is dropped, Orca boots as a GUI, loses the single-instance lock to an already-running window, and exits silently with no stdout. Add a packaged-CLI-entry redirect that detects a Windows launch whose argv carries the known in-package CLI entrypoint and re-runs it in Electron node mode before the single-instance lock gate can bounce it, then exits with the CLI's status. Mirrors the existing maybeRedirectAppImageCliLaunch pattern. Fixes #5774 Co-authored-by: brennanb2025 <brennanb2025@users.noreply.github.com>
This commit is contained in:
co-authored by
brennanb2025
parent
8a39450b18
commit
f768e6b503
@@ -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<void> = Promise.resolve()
|
||||
let localPtyStartupReady: Promise<void> = 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 <unpacked CLI entry>) 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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 <unpacked CLI entry>`
|
||||
* 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<Buffer>
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user