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 <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-13 01:45:54 -07:00
committed by GitHub
co-authored by m4air
parent 8cd5bb2a74
commit 5929350944
10 changed files with 284 additions and 32 deletions
@@ -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"
@@ -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
}
}
+11 -1
View File
@@ -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: [] },
+7 -27
View File
@@ -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<SpawnOptions, 'shell'> {
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
@@ -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(
[]
)
})
})
+2
View File
@@ -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',
+118 -1
View File
@@ -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<NonNullable<CliLaunchRedirectOptions['run']>>()
const execve = vi.fn<NonNullable<CliLaunchRedirectOptions['execve']>>()
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<NonNullable<CliLaunchRedirectOptions['execve']>>()
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'],
+84 -2
View File
@@ -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 `<binary> 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<CliLaunchRedirectOptions['execve']>,
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
}
+1 -1
View File
@@ -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,
@@ -0,0 +1,5 @@
/**
* Set on the CLI that a direct `<binary> --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'