From 592935094409013b87f0ce3ecaee8eb508cbe619 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 13 Oct 2026 01:45:54 -0700 Subject: [PATCH] fix(linux): direct AppImage serve honours the chosen profile and --supervise (#28227) * fix(linux): run a direct AppImage serve through the CLI so it keeps its profile and supervisor A direct `Orca.AppImage serve` stayed in the Electron process and skipped the CLI's serve front end, so it ignored ORCA_USER_DATA_PATH and silently dropped --supervise. It now execs into the bundled CLI (same pid, so service managers and signals still reach it), translating a leading --user-data-dir into ORCA_USER_DATA_PATH. Also lists terminal-daemon and reference as CLI commands so a direct binary launch of them reaches the CLI. Fixes BUG-1, BUG-3 and BUG-10 from the real-host Linux test run. * fix(linux): drop desktop switches anywhere in a direct serve launch * fix(linux): keep a direct serve's --no-sandbox for the Electron server the CLI starts --------- Co-authored-by: m4air --- .../run-signal-case.sh | 8 ++ src/cli/runtime/electron-sandbox-switch.ts | 36 ++++++ src/cli/runtime/launch.test.ts | 12 +- src/cli/runtime/launch.ts | 34 ++--- .../specs/cli-command-names-parity.test.ts | 12 ++ src/main/startup/cli-command-names.ts | 2 + src/main/startup/cli-launch-redirect.test.ts | 119 +++++++++++++++++- src/main/startup/cli-launch-redirect.ts | 86 ++++++++++++- src/main/startup/main-process-preflight.ts | 2 +- src/shared/electron-no-sandbox-request.ts | 5 + 10 files changed, 284 insertions(+), 32 deletions(-) create mode 100644 src/cli/runtime/electron-sandbox-switch.ts create mode 100644 src/cli/specs/cli-command-names-parity.test.ts create mode 100644 src/shared/electron-no-sandbox-request.ts diff --git a/config/docker/headless-serve-shutdown/run-signal-case.sh b/config/docker/headless-serve-shutdown/run-signal-case.sh index efbca6592fc..5d1979462aa 100755 --- a/config/docker/headless-serve-shutdown/run-signal-case.sh +++ b/config/docker/headless-serve-shutdown/run-signal-case.sh @@ -38,6 +38,8 @@ export XDG_CONFIG_HOME="$state_dir/config" export XDG_CACHE_HOME="$state_dir/cache" export XDG_RUNTIME_DIR="$state_dir/runtime" export LIBGL_ALWAYS_SOFTWARE=1 +# Every entrypoint must serve the profile the caller chose, not the default one. +export ORCA_USER_DATA_PATH="$state_dir/profile" mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_RUNTIME_DIR" chmod 700 "$XDG_RUNTIME_DIR" @@ -81,6 +83,12 @@ if [[ -z "$ready_line" ]]; then exit 1 fi +if [[ ! -f "$ORCA_USER_DATA_PATH/orca-runtime.json" || -e "$XDG_CONFIG_HOME/orca/orca-runtime.json" ]]; then + ls -la "$ORCA_USER_DATA_PATH" "$XDG_CONFIG_HOME" >&2 || true + echo "FAIL: serve ignored ORCA_USER_DATA_PATH; its runtime metadata is not in $ORCA_USER_DATA_PATH" >&2 + exit 1 +fi + registered_cli_verified=false if [[ "$entrypoint_kind" == appimage ]]; then registered_cli="$HOME/.local/bin/orca-ide" diff --git a/src/cli/runtime/electron-sandbox-switch.ts b/src/cli/runtime/electron-sandbox-switch.ts new file mode 100644 index 00000000000..ae5c98a8ea8 --- /dev/null +++ b/src/cli/runtime/electron-sandbox-switch.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { runProcessSync } from '@orca/process-host' +import { ELECTRON_NO_SANDBOX_REQUEST_ENV } from '../../shared/electron-no-sandbox-request' + +const USER_NAMESPACE_PROBE_TIMEOUT_MS = 2_000 + +/** Whether an Electron this CLI starts needs `--no-sandbox`. */ +export function needsElectronNoSandbox(executable: string): boolean { + return ( + process.env[ELECTRON_NO_SANDBOX_REQUEST_ENV] === '1' || + shouldDisableExtractedAppImageSandbox(executable) + ) +} + +function shouldDisableExtractedAppImageSandbox(executable: string): boolean { + if (process.platform !== 'linux' || !existsSync(join(dirname(executable), 'AppRun'))) { + return false + } + // An extracted AppImage has no root-owned setuid sandbox; mirror AppRun's userns fallback. + if (process.getuid?.() === 0) { + return true + } + try { + return ( + runProcessSync({ + program: 'unshare', + args: ['-Ur', 'true'], + stdio: 'ignore', + timeoutMs: USER_NAMESPACE_PROBE_TIMEOUT_MS + }).code !== 0 + ) + } catch { + return true + } +} diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index 999e5f84815..899c393a53a 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -23,7 +23,7 @@ vi.mock('child_process', () => ({ spawnSync: spawnSyncMock })) -import { launchOrcaApp, serveOrcaApp } from './launch' +import { getExecutableAppArgs, launchOrcaApp, serveOrcaApp, stripElectronRunAsNode } from './launch' class FakeChildProcess extends EventEmitter { stdout = new EventEmitter() @@ -521,6 +521,16 @@ describe('serveOrcaApp', () => { ) }) + it("re-applies a direct launch's --no-sandbox to the Electron it starts, not to terminals", () => { + process.env.ORCA_ELECTRON_NO_SANDBOX = '1' + try { + expect(getExecutableAppArgs('/opt/Orca/orca-ide')).toEqual(['--no-sandbox']) + expect(stripElectronRunAsNode(process.env)).not.toHaveProperty('ORCA_ELECTRON_NO_SANDBOX') + } finally { + delete process.env.ORCA_ELECTRON_NO_SANDBOX + } + }) + it.each([ { probe: 'exits nonzero', result: { status: 1 }, expectedPrefix: ['--no-sandbox'] }, { probe: 'succeeds', result: { status: 0 }, expectedPrefix: [] }, diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index 505566b71fa..34b5ea1f5d1 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,7 +1,6 @@ import { spawn, type SpawnOptions } from 'node:child_process' -import { existsSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { runProcessSync, spawnProcess } from '@orca/process-host' +import { resolve } from 'node:path' +import { spawnProcess } from '@orca/process-host' import type { ProcessSpec } from '@orca/process-host/process-spec' import { SERVE_UPDATE_HANDOFF_PATH_ENV, @@ -13,6 +12,8 @@ import { resolveLaunchUserDataPath } from './launch-user-data-path' import { foregroundServeIsolatedProfileSwitches } from '../../shared/electron-isolated-profile-switches' +import { ELECTRON_NO_SANDBOX_REQUEST_ENV } from '../../shared/electron-no-sandbox-request' +import { needsElectronNoSandbox } from './electron-sandbox-switch' import { getMacAppBundlePath } from './mac-app-update-bundle' import { getPlatformUserDataPath } from './metadata' import { isSameUserDataPath } from '../../shared/serve-user-data-path' @@ -50,7 +51,6 @@ import { type ServeSpawnChild } from './serve-pairing-redaction' -const USER_NAMESPACE_PROBE_TIMEOUT_MS = 2_000 const STARTUP_ABORT_MS = 30_000 export function launchOrcaApp(): void { @@ -284,34 +284,12 @@ function spawnElectronServe( export function getExecutableAppArgs(executable: string): string[] { const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : [] - if (shouldDisableExtractedAppImageSandbox(executable)) { + if (needsElectronNoSandbox(executable)) { args.push('--no-sandbox') } return args } -function shouldDisableExtractedAppImageSandbox(executable: string): boolean { - if (process.platform !== 'linux' || !existsSync(join(dirname(executable), 'AppRun'))) { - return false - } - // An extracted AppImage has no root-owned setuid sandbox; mirror AppRun's userns fallback. - if (process.getuid?.() === 0) { - return true - } - try { - return ( - runProcessSync({ - program: 'unshare', - args: ['-Ur', 'true'], - stdio: 'ignore', - timeoutMs: USER_NAMESPACE_PROBE_TIMEOUT_MS - }).code !== 0 - ) - } catch { - return true - } -} - function getExecutableSpawnOptions(executable: string): Pick { return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable) ? { shell: true } : {} } @@ -338,6 +316,8 @@ export function resolveForegroundOrcaExecutable(): string { export function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next = { ...env } delete next.ELECTRON_RUN_AS_NODE + // Why: the request is applied as a switch; the server's terminals must not inherit it. + delete next[ELECTRON_NO_SANDBOX_REQUEST_ENV] // Why: only the relocated supervisor itself may resolve its app root this way. delete next[SERVE_SUPERVISOR_APP_ROOT_ENV] return next diff --git a/src/cli/specs/cli-command-names-parity.test.ts b/src/cli/specs/cli-command-names-parity.test.ts new file mode 100644 index 00000000000..a1f9394d0bf --- /dev/null +++ b/src/cli/specs/cli-command-names-parity.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { CLI_COMMAND_NAMES } from '../../main/startup/cli-command-names' +import { COMMAND_SPECS } from './index' + +describe('CLI launch redirect command names', () => { + it('lists every CLI command, so a direct binary launch never boots the desktop app', () => { + const roots = new Set(COMMAND_SPECS.map((spec) => spec.path[0])) + expect([...roots].filter((root) => !CLI_COMMAND_NAMES.some((name) => name === root))).toEqual( + [] + ) + }) +}) diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 22d90947ced..50ea192a3c4 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -50,6 +50,7 @@ export const CLI_COMMAND_NAMES = [ 'pdf', 'profile', 'project', + 'reference', 'reload', 'repo', 'screenshot', @@ -66,6 +67,7 @@ export const CLI_COMMAND_NAMES = [ 'storage', 'tab', 'terminal', + 'terminal-daemon', 'type', 'uncheck', 'upload', diff --git a/src/main/startup/cli-launch-redirect.test.ts b/src/main/startup/cli-launch-redirect.test.ts index 7a4b29fe60e..78f5a0bdd51 100644 --- a/src/main/startup/cli-launch-redirect.test.ts +++ b/src/main/startup/cli-launch-redirect.test.ts @@ -1,6 +1,10 @@ import { posix, win32 } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { getCliLaunchArgs, maybeRedirectCliLaunch } from './cli-launch-redirect' +import { + getCliLaunchArgs, + maybeRedirectCliLaunch, + type CliLaunchRedirectOptions +} from './cli-launch-redirect' const COMMAND_NAMES = ['project', 'serve', 'status', 'skills', 'worktree'] @@ -337,6 +341,119 @@ describe('CLI launch redirect: spawning', () => { expect(run).not.toHaveBeenCalled() }) + it('execs a direct serve into the CLI so it keeps its pid and honours profile and supervise', () => { + const run = vi.fn>() + const execve = vi.fn>() + + const result = maybeRedirectCliLaunch({ + argv: [ + linux.execPath, + '--no-sandbox', + '--user-data-dir', + '/home/u/profiles/b1', + 'serve', + '--supervise', + '--port', + '6792', + '--no-sandbox' + ], + env: { NODE_OPTIONS: '--inspect', APPIMAGE: '/home/u/Orca.AppImage' }, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true, + run, + execve + }) + + expect(run).not.toHaveBeenCalled() + expect(result).toEqual({ redirected: false }) + expect(execve).toHaveBeenCalledWith( + linux.execPath, + [linux.execPath, linux.cliEntryPath, 'serve', '--supervise', '--port', '6792'], + expect.objectContaining({ + ELECTRON_RUN_AS_NODE: '1', + ORCA_USER_DATA_PATH: '/home/u/profiles/b1', + ORCA_NODE_OPTIONS: '--inspect', + APPIMAGE: '/home/u/Orca.AppImage' + }) + ) + const env = execve.mock.calls[0]?.[2] + // Why: the CLI must still start its Electron server with the caller's --no-sandbox. + expect(env).toHaveProperty('ORCA_ELECTRON_NO_SANDBOX', '1') + expect(env).not.toHaveProperty('ORCA_CLI_LAUNCH_REDIRECTED') + expect(env).not.toHaveProperty('NODE_OPTIONS') + }) + + it('keeps ORCA_USER_DATA_PATH for a direct serve without --user-data-dir', () => { + const execve = vi.fn>() + maybeRedirectCliLaunch({ + argv: [linux.execPath, 'serve', '--no-pairing'], + env: { ORCA_USER_DATA_PATH: '/home/u/profiles/b1' }, + platform: 'linux', + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true, + execve + }) + expect(execve).toHaveBeenCalledWith( + linux.execPath, + [linux.execPath, linux.cliEntryPath, 'serve', '--no-pairing'], + expect.objectContaining({ ORCA_USER_DATA_PATH: '/home/u/profiles/b1' }) + ) + expect(execve.mock.calls[0]?.[2]).not.toHaveProperty('ORCA_ELECTRON_NO_SANDBOX') + }) + + it('serves in-process when this runtime cannot exec or the exec fails', () => { + const base = { + argv: [linux.execPath, 'serve'], + env: {}, + platform: 'linux' as const, + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true + } + expect(maybeRedirectCliLaunch({ ...base, execve: null })).toEqual({ redirected: false }) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + expect( + maybeRedirectCliLaunch({ + ...base, + execve: () => { + throw new Error('ENOEXEC') + } + }) + ).toEqual({ redirected: false }) + expect(stderr).toHaveBeenCalledWith(expect.stringContaining('ENOEXEC')) + stderr.mockRestore() + }) + + it('never execs the --serve child the CLI itself spawns, or serve on other platforms', () => { + const execve = vi.fn() + for (const [argv, platform] of [ + [[linux.execPath, '--user-data-dir=/p', '--serve', '--serve-port', '0'], 'linux'], + [[linux.execPath, 'serve'], 'darwin'] + ] as const) { + maybeRedirectCliLaunch({ + argv: [...argv], + env: {}, + platform, + isPackaged: true, + resourcesPath: linux.resourcesPath, + execPath: linux.execPath, + commandNames: COMMAND_NAMES, + exists: () => true, + execve + }) + } + expect(execve).not.toHaveBeenCalled() + }) + it('surfaces a spawn failure as a non-zero exit', () => { const result = maybeRedirectCliLaunch({ argv: [linux.execPath, 'status'], diff --git a/src/main/startup/cli-launch-redirect.ts b/src/main/startup/cli-launch-redirect.ts index 09fd57cb640..b80b5b7826f 100644 --- a/src/main/startup/cli-launch-redirect.ts +++ b/src/main/startup/cli-launch-redirect.ts @@ -1,7 +1,8 @@ import { existsSync } from 'node:fs' -import { posix, win32 } from 'node:path' +import { posix, resolve, win32 } from 'node:path' import { runProcessSync } from '@orca/process-host' import { CLI_BOOLEAN_FLAGS, findCliCommandIndex } from '../../shared/cli-argument-boundary' +import { ELECTRON_NO_SANDBOX_REQUEST_ENV } from '../../shared/electron-no-sandbox-request' import { CLI_COMMAND_NAMES } from './cli-command-names' import { VALUE_TAKING_FLAGS } from './serve-mode-argv' @@ -17,6 +18,8 @@ export type CliLaunchRedirectOptions = { commandNames?: readonly string[] exists?: typeof existsSync run?: typeof runProcessSync + /** Replaces this process; null when the runtime cannot, which keeps serve in-process. */ + execve?: ((file: string, args: string[], env: NodeJS.ProcessEnv) => void) | null } const CLI_EARLY_EXIT_FLAGS = new Set(['--help', '-h', 'help', '--version', '-v']) @@ -47,6 +50,15 @@ export function maybeRedirectCliLaunch( }) if (!cliArgs) { + const serve = getDirectServeLaunch(argv, { + platform, + isPackaged, + commandNames: options.commandNames ?? CLI_COMMAND_NAMES + }) + const execve = options.execve === undefined ? defaultExecve() : options.execve + if (serve && execve && exists(cliEntryPath)) { + execDirectServe(execve, execPath, cliEntryPath, serve, env) + } return { redirected: false } } if (env[REDIRECT_ATTEMPT_ENV] === '1') { @@ -74,6 +86,76 @@ export function maybeRedirectCliLaunch( } } +type DirectServeLaunch = { args: string[]; userDataPath: string | null; noSandbox: boolean } + +/** + * A direct ` serve` on Linux. It runs through the CLI like `orca-ide serve` does, so it + * honours ORCA_USER_DATA_PATH and --supervise; exec keeps the pid, so signals still reach it. + */ +export function getDirectServeLaunch( + argv: string[], + options: { platform: NodeJS.Platform; isPackaged: boolean; commandNames: readonly string[] } +): DirectServeLaunch | null { + if (!options.isPackaged || options.platform !== 'linux') { + return null + } + const args = argv.slice(1) + const commandIndex = findCommandIndex(args, options.commandNames) + if (commandIndex === -1 || args[commandIndex] !== 'serve') { + return null + } + // Why: the CLI has no --user-data-dir; it reads the profile from ORCA_USER_DATA_PATH. + let userDataPath: string | null = null + const leading: string[] = [] + for (let index = 0; index < commandIndex; index += 1) { + const arg = args[index]! + if (arg.startsWith('--user-data-dir=')) { + userDataPath = arg.slice('--user-data-dir='.length) + } else if (arg === '--user-data-dir' && index + 1 < commandIndex) { + userDataPath = args[++index]! + } else { + leading.push(arg) + } + } + const rest = [...leading, ...args.slice(commandIndex)] + return { + // Why everywhere: serve has no such flags, and in-process serve tolerated them after the command. + args: stripDesktopFlags(rest, rest.length), + userDataPath: userDataPath ? resolve(userDataPath) : null, + noSandbox: rest.includes('--no-sandbox') + } +} + +function execDirectServe( + execve: NonNullable, + execPath: string, + cliEntryPath: string, + serve: DirectServeLaunch, + env: NodeJS.ProcessEnv +): void { + const childEnv = buildElectronRunAsNodeEnv(env) + // Why no fence: exec sets ELECTRON_RUN_AS_NODE itself, and the server's terminals inherit this env. + delete childEnv[REDIRECT_ATTEMPT_ENV] + if (serve.userDataPath) { + childEnv.ORCA_USER_DATA_PATH = serve.userDataPath + } + if (serve.noSandbox) { + childEnv[ELECTRON_NO_SANDBOX_REQUEST_ENV] = '1' + } + try { + execve(execPath, [execPath, cliEntryPath, ...serve.args], childEnv) + } catch (error) { + process.stderr.write( + `[serve] Serving in-process; could not start the Orca CLI: ${error instanceof Error ? error.message : String(error)}\n` + ) + } +} + +function defaultExecve(): CliLaunchRedirectOptions['execve'] { + const execve = process.execve + return execve ? (file, args, env) => execve.call(process, file, args, env) : null +} + export function getCliLaunchArgs( argv: string[], cliEntryPath: string, @@ -118,7 +200,7 @@ function getCommandLaunchArgs( const commandIndex = findCommandIndex(args, options.commandNames) const cliArgs = stripDesktopFlags(args, commandIndex) const command = commandIndex === -1 ? null : args[commandIndex] - // Keep direct serve in-process so signals reach its full child tree. + // Direct serve is exec'd into the CLI instead (getDirectServeLaunch), keeping its pid. if (command && command !== 'serve') { return cliArgs } diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 34fb415cf5a..00b886db297 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -136,7 +136,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b } // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. // The redirect runs before the serve-argv rewrite so it still matches on the launch argv verbatim. - // Direct serve stays in-process so its signal handlers own all children. + // A direct Linux `serve` is exec'd into the CLI here (same pid); it only continues in-process if exec fails. const cliLaunchRedirect = maybeRedirectCliLaunch({ isPackaged: app.isPackaged, resourcesPath: process.resourcesPath, diff --git a/src/shared/electron-no-sandbox-request.ts b/src/shared/electron-no-sandbox-request.ts new file mode 100644 index 00000000000..9b3154c8b9b --- /dev/null +++ b/src/shared/electron-no-sandbox-request.ts @@ -0,0 +1,5 @@ +/** + * Set on the CLI that a direct ` --no-sandbox serve` execs into: the CLI has no Chromium + * switches of its own, so it re-applies the caller's --no-sandbox to every Electron it starts. + */ +export const ELECTRON_NO_SANDBOX_REQUEST_ENV = 'ORCA_ELECTRON_NO_SANDBOX'