From 1d8153b42e932bb2cb6855daa0b016790fd61ed8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:41:04 -0700 Subject: [PATCH] fix(daemon): fork the detached daemon through a utility process so it stops inheriting Chromium descriptors --- .../scripts/electron-builder-config.test.mjs | 18 ++ electron.vite.config.ts | 5 + .../utility-shim-exiting-daemon.cjs | 3 + .../utility-shim-ready-daemon.cjs | 5 + .../daemon/daemon-init-utility-fork.test.ts | 160 +++++++++++ .../daemon/daemon-utility-fork-messages.ts | 28 ++ .../daemon-utility-launcher-shim.test.ts | 166 ++++++++++++ .../daemon/daemon-utility-launcher-shim.ts | 124 +++++++++ .../daemon-utility-process-fork.test.ts | 170 ++++++++++++ .../daemon/daemon-utility-process-fork.ts | 255 ++++++++++++++++++ .../electron-daemon-utility-process-fork.ts | 14 + src/main/index.ts | 6 + .../host-port-bootstrap-wiring.test.ts | 1 + 13 files changed, 955 insertions(+) create mode 100644 src/main/daemon/__fixtures__/utility-shim-exiting-daemon.cjs create mode 100644 src/main/daemon/__fixtures__/utility-shim-ready-daemon.cjs create mode 100644 src/main/daemon/daemon-init-utility-fork.test.ts create mode 100644 src/main/daemon/daemon-utility-fork-messages.ts create mode 100644 src/main/daemon/daemon-utility-launcher-shim.test.ts create mode 100644 src/main/daemon/daemon-utility-launcher-shim.ts create mode 100644 src/main/daemon/daemon-utility-process-fork.test.ts create mode 100644 src/main/daemon/daemon-utility-process-fork.ts create mode 100644 src/main/host/electron-daemon-utility-process-fork.ts diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 0a42eea5067..112dde85259 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -262,6 +262,24 @@ describe('electron-builder config', () => { ) }) + // Why: the descriptor-clean daemon fork loads this entry with + // utilityProcess.fork; a dropped rollup input would silently put every + // Linux/Windows launch on the direct-fork fallback, resurrecting the + // inherited-descriptor leak into the daemon and its PTY children. + it('bundles the utility launcher shim the descriptor-clean daemon fork loads', async () => { + const forkSource = await readFile( + join(SRC_MAIN_DIR, 'daemon', 'daemon-utility-process-fork.ts'), + 'utf8' + ) + const entryFilename = forkSource.match(/'(daemon-utility-launcher-shim\.js)'/)?.[1] + expect(entryFilename).toBeDefined() + + const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8') + expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`)) + // utilityProcess reads asar directly, so the shim deliberately stays packed. + expect(electronBuilderConfig.asarUnpack).not.toContain(`out/main/${entryFilename}`) + }) + it('uses the multi-size icon source for Linux packages', () => { expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns') }) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4faa844ee4c..f71fdb55d8c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -211,6 +211,11 @@ export const electronViteConfig: UserConfig = { 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), 'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), + // Why: forked as an Electron utility process so the detached daemon it + // spawns starts without inherited Chromium descriptors (Linux/Windows). + 'daemon-utility-launcher-shim': resolve( + 'src/main/daemon/daemon-utility-launcher-shim.ts' + ), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), 'stt-worker': resolve('src/main/speech/stt-worker.ts'), diff --git a/src/main/daemon/__fixtures__/utility-shim-exiting-daemon.cjs b/src/main/daemon/__fixtures__/utility-shim-exiting-daemon.cjs new file mode 100644 index 00000000000..255074eeb42 --- /dev/null +++ b/src/main/daemon/__fixtures__/utility-shim-exiting-daemon.cjs @@ -0,0 +1,3 @@ +// Fake daemon that dies during startup so the shim's exit relay is observable. +process.send({ type: 'starting' }) +process.exit(7) diff --git a/src/main/daemon/__fixtures__/utility-shim-ready-daemon.cjs b/src/main/daemon/__fixtures__/utility-shim-ready-daemon.cjs new file mode 100644 index 00000000000..e8d44609d6f --- /dev/null +++ b/src/main/daemon/__fixtures__/utility-shim-ready-daemon.cjs @@ -0,0 +1,5 @@ +// Fake daemon for the utility-launcher shim tests: reports ready over IPC, +// writes a stderr marker, then waits to be killed (self-exits as a backstop). +process.send({ type: 'ready', startedAtMs: 123 }) +process.stderr.write('utility-shim-fixture-stderr') +setTimeout(() => process.exit(0), 15000) diff --git a/src/main/daemon/daemon-init-utility-fork.test.ts b/src/main/daemon/daemon-init-utility-fork.test.ts new file mode 100644 index 00000000000..660df8b3d46 --- /dev/null +++ b/src/main/daemon/daemon-init-utility-fork.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FAKE_DAEMON_ENTRY_PATH, FAKE_USER_DATA_PATH } from './daemon-init-test-harness' + +const { + forkMock, + checkDaemonHealthMock, + daemonClientMock, + spawnerInstances, + importFresh, + installDefaultNetConnectStub, + moduleFactories +} = await vi.hoisted(async () => + (await import('./daemon-init-test-harness')).createDaemonInitMocks() +) + +const { canForkThroughUtilityMock, forkThroughUtilityMock } = vi.hoisted(() => ({ + canForkThroughUtilityMock: vi.fn(() => false), + forkThroughUtilityMock: vi.fn() +})) + +vi.mock('fs', () => moduleFactories.fs()) +vi.mock('child_process', async (importOriginal) => + moduleFactories.childProcess(await importOriginal>()) +) +vi.mock('net', () => moduleFactories.net()) +vi.mock('./daemon-health', () => moduleFactories.daemonHealth()) +vi.mock('./daemon-pid-identity', () => moduleFactories.daemonPidIdentity()) +vi.mock('./daemon-tcc-attribution', () => moduleFactories.daemonTccAttribution()) +vi.mock('./daemon-bundle-staleness', () => moduleFactories.daemonBundleStaleness()) +vi.mock('./daemon-stale-kill', () => moduleFactories.daemonStaleKill()) +vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartTime()) +vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse()) +vi.mock('./client', () => moduleFactories.client()) +vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent()) +vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner()) +vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter()) +vi.mock('../ipc/pty', () => moduleFactories.ipcPty()) +vi.mock('./daemon-utility-process-fork', () => ({ + canForkDaemonThroughUtilityProcess: canForkThroughUtilityMock, + forkDaemonThroughUtilityProcess: forkThroughUtilityMock +})) + +function fakeLaunchedChild(): { + pid: number + on(event: string, cb: (arg?: unknown) => void): unknown + off(): unknown + disconnect: ReturnType + unref: ReturnType +} { + return { + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) + } + return this + }, + off() { + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + } +} + +/** importFresh resets forkMock, so callers must queue fork results AFTER this. */ +async function primeLauncher(): Promise< + (socketPath: string, tokenPath: string) => Promise +> { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + return spawnerInstances[0].launcher as (socketPath: string, tokenPath: string) => Promise +} + +describe('daemon-init: descriptor-clean daemon fork', () => { + beforeEach(() => { + installDefaultNetConnectStub() + canForkThroughUtilityMock.mockReset() + canForkThroughUtilityMock.mockReturnValue(false) + forkThroughUtilityMock.mockReset() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('forks through the utility-process launcher when it is available', async () => { + const launcher = await primeLauncher() + canForkThroughUtilityMock.mockReturnValue(true) + forkThroughUtilityMock.mockImplementation(async () => fakeLaunchedChild()) + // Why: the harness's endpoint-identity reader derives the launch nonce from + // forkMock's argv; the utility path never calls forkMock, so read it from + // the utility spec instead. + daemonClientMock.mockImplementation(function MockUtilityAdoptionClient() { + return { + ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(() => { + const spec = forkThroughUtilityMock.mock.calls.at(-1)?.[0] as + | { args: string[] } + | undefined + const nonceIndex = spec ? spec.args.indexOf('--launch-nonce') : -1 + return nonceIndex >= 0 && spec + ? { pid: 12345, startedAtMs: 1_000_000, launchNonce: spec.args[nonceIndex + 1] } + : null + }), + request: vi.fn(async () => ({ sessions: [] })), + disconnect: vi.fn() + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(forkThroughUtilityMock).toHaveBeenCalledOnce() + expect(forkMock).not.toHaveBeenCalled() + const spec = forkThroughUtilityMock.mock.calls[0][0] as { + entryPath: string + args: string[] + cwd: string + env: NodeJS.ProcessEnv + execPath: string + } + expect(spec.entryPath).toBe(FAKE_DAEMON_ENTRY_PATH) + expect(spec.cwd).toBe(FAKE_USER_DATA_PATH) + expect(spec.execPath).toBe(process.execPath) + expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1') + expect(spec.env.ORCA_USER_DATA_PATH).toBe(FAKE_USER_DATA_PATH) + expect(spec.args).toEqual( + expect.arrayContaining(['--socket', '/fake/socket', '--entry-path', FAKE_DAEMON_ENTRY_PATH]) + ) + }) + + it('falls back to the direct fork when the utility launch fails, so the daemon still exists', async () => { + const launcher = await primeLauncher() + canForkThroughUtilityMock.mockReturnValue(true) + forkThroughUtilityMock.mockRejectedValue(new Error('utility spawn refused')) + forkMock.mockReturnValueOnce(fakeLaunchedChild()) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + await launcher('/fake/socket', '/fake/token') + } finally { + warnSpy.mockRestore() + } + + expect(forkThroughUtilityMock).toHaveBeenCalledOnce() + expect(forkMock).toHaveBeenCalledOnce() + }) + + it('keeps the direct fork where the utility hop is unavailable (macOS, plain-node hosts)', async () => { + const launcher = await primeLauncher() + canForkThroughUtilityMock.mockReturnValue(false) + forkMock.mockReturnValueOnce(fakeLaunchedChild()) + + await launcher('/fake/socket', '/fake/token') + + expect(forkThroughUtilityMock).not.toHaveBeenCalled() + expect(forkMock).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/daemon/daemon-utility-fork-messages.ts b/src/main/daemon/daemon-utility-fork-messages.ts new file mode 100644 index 00000000000..17ad0b1417f --- /dev/null +++ b/src/main/daemon/daemon-utility-fork-messages.ts @@ -0,0 +1,28 @@ +/** + * Wire types between the daemon-forking main process and the utility-process + * launcher shim. Both sides bundle separately, so the contract lives alone. + */ + +export type UtilityDaemonForkSpec = { + /** Absolute path to daemon-entry.js (unpacked in packaged builds). */ + entryPath: string + args: readonly string[] + cwd: string + /** Full daemon environment, composed by main — never placed in argv. */ + env: NodeJS.ProcessEnv + /** Binary to run as plain Node; the relocated Windows host when staged. */ + execPath: string +} + +export type DaemonShimDownMessage = + | { kind: 'spawn'; spec: UtilityDaemonForkSpec } + | { kind: 'release' } + +export type DaemonShimUpMessage = + | { kind: 'shim-ready' } + | { kind: 'spawned'; pid: number } + | { kind: 'spawn-error'; message: string } + | { kind: 'daemon-message'; message: unknown } + | { kind: 'daemon-stderr'; text: string } + | { kind: 'daemon-error'; message: string } + | { kind: 'daemon-exit'; code: number | null; signal: NodeJS.Signals | null } diff --git a/src/main/daemon/daemon-utility-launcher-shim.test.ts b/src/main/daemon/daemon-utility-launcher-shim.test.ts new file mode 100644 index 00000000000..cb4433dda42 --- /dev/null +++ b/src/main/daemon/daemon-utility-launcher-shim.test.ts @@ -0,0 +1,166 @@ +import { EventEmitter } from 'node:events' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runDaemonUtilityLauncherShim, type ShimParentPort } from './daemon-utility-launcher-shim' +import type { DaemonShimUpMessage, UtilityDaemonForkSpec } from './daemon-utility-fork-messages' + +const READY_FIXTURE = join(__dirname, '__fixtures__', 'utility-shim-ready-daemon.cjs') +const EXITING_FIXTURE = join(__dirname, '__fixtures__', 'utility-shim-exiting-daemon.cjs') + +type FakePort = ShimParentPort & { + posted: DaemonShimUpMessage[] + deliver(message: unknown): void + started: boolean + waitFor( + kind: K, + timeoutMs?: number + ): Promise> +} + +function createFakePort(): FakePort { + const emitter = new EventEmitter() + const posted: DaemonShimUpMessage[] = [] + const port: FakePort = { + posted, + started: false, + on(_event, listener) { + emitter.on('message', listener) + return port + }, + start() { + port.started = true + }, + postMessage(message) { + posted.push(message as DaemonShimUpMessage) + emitter.emit('posted', message) + }, + deliver(message) { + emitter.emit('message', { data: message }) + }, + waitFor(kind, timeoutMs = 10_000) { + const existing = posted.find((message) => message.kind === kind) + if (existing) { + return Promise.resolve(existing as Extract) + } + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`timed out waiting for shim message ${kind}`)), + timeoutMs + ) + const onPosted = (message: DaemonShimUpMessage): void => { + if (message.kind === kind) { + clearTimeout(timer) + emitter.off('posted', onPosted) + resolve(message as Extract) + } + } + emitter.on('posted', onPosted) + }) + } + } + return port +} + +function specFor( + entryPath: string, + overrides: Partial = {} +): UtilityDaemonForkSpec { + return { + entryPath, + args: ['--socket', '/fake/sock'], + cwd: process.cwd(), + env: { ...process.env, ORCA_SHIM_TEST: '1' }, + execPath: process.execPath, + ...overrides + } +} + +const spawnedPids: number[] = [] + +afterEach(() => { + for (const pid of spawnedPids.splice(0)) { + try { + process.kill(pid, 'SIGKILL') + } catch { + // already gone + } + } +}) + +describe('daemon-utility-launcher-shim', () => { + it('spawns the daemon detached as plain args on the provided binary', () => { + const port = createFakePort() + const child = new EventEmitter() as EventEmitter & { pid: number; stderr: null } + child.pid = 4242 + child.stderr = null + const spawn = vi.fn(() => child as never) + runDaemonUtilityLauncherShim(port, spawn, () => {}) + + expect(port.started).toBe(true) + expect(port.posted[0]).toEqual({ kind: 'shim-ready' }) + + const spec = specFor('/fake/daemon-entry.js') + port.deliver({ kind: 'spawn', spec }) + expect(spawn).toHaveBeenCalledWith({ + program: process.execPath, + args: ['/fake/daemon-entry.js', '--socket', '/fake/sock'], + cwd: spec.cwd, + env: spec.env, + detached: true, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + expect(port.posted).toContainEqual({ kind: 'spawned', pid: 4242 }) + + // A duplicate spawn request must not fork a second daemon. + port.deliver({ kind: 'spawn', spec }) + expect(spawn).toHaveBeenCalledTimes(1) + }) + + it('reports a spawn failure and exits nonzero', () => { + const port = createFakePort() + const exit = vi.fn() + runDaemonUtilityLauncherShim( + port, + () => { + throw new Error('no binary') + }, + exit + ) + port.deliver({ kind: 'spawn', spec: specFor('/fake/daemon-entry.js') }) + expect(port.posted).toContainEqual({ kind: 'spawn-error', message: 'no binary' }) + expect(exit).toHaveBeenCalledWith(1) + }) + + it('relays IPC readiness and stderr from a real daemon child', async () => { + const port = createFakePort() + const exit = vi.fn() + runDaemonUtilityLauncherShim(port, undefined, exit) + port.deliver({ kind: 'spawn', spec: specFor(READY_FIXTURE) }) + + const spawned = await port.waitFor('spawned') + spawnedPids.push(spawned.pid) + expect(spawned.pid).toBeGreaterThan(0) + + const ready = await port.waitFor('daemon-message') + expect(ready.message).toMatchObject({ type: 'ready', startedAtMs: 123 }) + + const stderr = await port.waitFor('daemon-stderr') + expect(stderr.text).toContain('utility-shim-fixture-stderr') + + // Release must detach without killing: the shim exits, the daemon stays. + port.deliver({ kind: 'release' }) + expect(exit).toHaveBeenCalledWith(0) + expect(() => process.kill(spawned.pid, 0)).not.toThrow() + }) + + it('relays the daemon exit code from a real child', async () => { + const port = createFakePort() + runDaemonUtilityLauncherShim(port, undefined, () => {}) + port.deliver({ kind: 'spawn', spec: specFor(EXITING_FIXTURE) }) + + const spawned = await port.waitFor('spawned') + spawnedPids.push(spawned.pid) + const exited = await port.waitFor('daemon-exit') + expect(exited).toEqual({ kind: 'daemon-exit', code: 7, signal: null }) + }) +}) diff --git a/src/main/daemon/daemon-utility-launcher-shim.ts b/src/main/daemon/daemon-utility-launcher-shim.ts new file mode 100644 index 00000000000..dfc79ba5153 --- /dev/null +++ b/src/main/daemon/daemon-utility-launcher-shim.ts @@ -0,0 +1,124 @@ +/** + * Utility-process launcher shim for the detached terminal daemon. + * + * Why this hop exists: the Electron main process carries Chromium-owned + * descriptors that are not close-on-exec (POSIX) / not inheritance-protected + * (Windows) — the DevTools CDP listener, the crashpad client channel, mojo + * socketpairs, and writable profile file descriptors among them. A daemon + * forked directly from main inherits all of them on Linux and Windows, passes + * them to every PTY child, and — because the daemon outlives the app — keeps + * dead-instance resources alive: the CDP port stays bound with no acceptor, so + * a relaunched app comes back debugger-less. A utility process is launched by + * Chromium's own process launcher, which grants children an explicit + * stdio/ipc-only descriptor set, so a daemon forked from HERE starts clean. + * + * Runs inside `utilityProcess.fork` with no window and no electron imports; + * talks to the main process only through `process.parentPort`. + */ +import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import type { + DaemonShimDownMessage, + DaemonShimUpMessage, + UtilityDaemonForkSpec +} from './daemon-utility-fork-messages' + +export type ShimParentPort = { + on(event: 'message', listener: (event: { data: unknown }) => void): unknown + postMessage(message: unknown): void + start?: () => void +} + +type ShimSpawn = (spec: { + program: string + args: readonly string[] + cwd: string + env: NodeJS.ProcessEnv + detached: boolean + stdio: ('ignore' | 'pipe' | 'ipc')[] +}) => SpawnedProcess + +/** Delay before self-exit after relaying the daemon's exit, so the message wins the race. */ +const EXIT_RELAY_LINGER_MS = 2000 + +export function runDaemonUtilityLauncherShim( + port: ShimParentPort, + spawn: ShimSpawn = spawnProcess, + exit: (code: number) => void = (code) => process.exit(code) +): void { + let child: SpawnedProcess | null = null + let launched = false + let released = false + + const post = (message: DaemonShimUpMessage): void => port.postMessage(message) + + const release = (): void => { + if (released) { + return + } + released = true + if (child) { + // Mirror of the direct-fork launcher: drop IPC and stderr so the daemon + // runs detached, then leave; the daemon must not die with this shim. + if (child.connected) { + child.disconnect() + } + child.stderr?.destroy() + child.unref() + } + exit(0) + } + + const launch = (spec: UtilityDaemonForkSpec): void => { + try { + child = spawn({ + program: spec.execPath, + args: [spec.entryPath, ...spec.args], + cwd: spec.cwd, + env: spec.env, + detached: true, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + }) + } catch (error) { + post({ kind: 'spawn-error', message: error instanceof Error ? error.message : String(error) }) + exit(1) + return + } + child.on('message', (message) => post({ kind: 'daemon-message', message })) + child.stderr?.on('data', (chunk: Buffer | string) => + post({ kind: 'daemon-stderr', text: chunk.toString('utf8') }) + ) + child.on('error', (error) => post({ kind: 'daemon-error', message: error.message })) + child.on('exit', (code, signal) => { + post({ kind: 'daemon-exit', code, signal }) + // The parent kills this shim on receipt; the linger only covers a parent + // that is already gone. + setTimeout(() => exit(0), EXIT_RELAY_LINGER_MS) + }) + if (typeof child.pid === 'number') { + post({ kind: 'spawned', pid: child.pid }) + } else { + post({ kind: 'spawn-error', message: 'daemon child has no pid' }) + exit(1) + } + } + + port.on('message', (event) => { + const message = event.data as DaemonShimDownMessage | null + if (!message || typeof message !== 'object') { + return + } + if (message.kind === 'spawn' && !launched) { + launched = true + launch(message.spec) + } else if (message.kind === 'release') { + release() + } + }) + port.start?.() + post({ kind: 'shim-ready' }) +} + +const parentPort = (process as unknown as { parentPort?: ShimParentPort }).parentPort +if (parentPort) { + runDaemonUtilityLauncherShim(parentPort) +} diff --git a/src/main/daemon/daemon-utility-process-fork.test.ts b/src/main/daemon/daemon-utility-process-fork.test.ts new file mode 100644 index 00000000000..adde373824f --- /dev/null +++ b/src/main/daemon/daemon-utility-process-fork.test.ts @@ -0,0 +1,170 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' +import { + canForkDaemonThroughUtilityProcess, + forkDaemonThroughUtilityProcess, + setDaemonUtilityProcessFork, + type UtilityProcessForkFn, + type UtilityProcessLike +} from './daemon-utility-process-fork' +import type { DaemonShimDownMessage, UtilityDaemonForkSpec } from './daemon-utility-fork-messages' + +class FakeShim extends EventEmitter implements UtilityProcessLike { + pid = 999 + posted: DaemonShimDownMessage[] = [] + killed = false + postMessage(message: unknown): void { + this.posted.push(message as DaemonShimDownMessage) + } + kill(): boolean { + this.killed = true + return true + } +} + +const SPEC: UtilityDaemonForkSpec = { + entryPath: '/fake/daemon-entry.js', + args: ['--socket', '/fake/sock'], + cwd: '/fake/userData', + env: { ELECTRON_RUN_AS_NODE: '1' }, + execPath: '/fake/electron' +} + +let shim: FakeShim +let forkedPaths: string[] + +const forkFn: UtilityProcessForkFn = (modulePath) => { + forkedPaths.push(modulePath) + return shim +} + +/** Runs the handshake to a resolved child: shim-ready -> spawn -> spawned. */ +async function forkSettledChild() { + const promise = forkDaemonThroughUtilityProcess(SPEC, forkFn) + shim.emit('message', { kind: 'shim-ready' }) + shim.emit('message', { kind: 'spawned', pid: 777 }) + return await promise +} + +beforeEach(() => { + shim = new FakeShim() + forkedPaths = [] + setAppEnvironment({ + getAppPath: () => '/fake/app', + getPath: () => '/fake/userData', + getVersion: () => '1.2.3', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + } as unknown as AppEnvironment) +}) + +afterEach(() => { + vi.useRealTimers() + setDaemonUtilityProcessFork(null) +}) + +describe('canForkDaemonThroughUtilityProcess', () => { + it('never uses the utility hop on macOS: posix_spawn already strips descriptors and TCC needs the direct fork', () => { + setDaemonUtilityProcessFork(forkFn) + expect(canForkDaemonThroughUtilityProcess('darwin')).toBe(false) + }) + + it('uses the utility hop on Linux and Windows once the desktop installs the port', () => { + setDaemonUtilityProcessFork(forkFn) + expect(canForkDaemonThroughUtilityProcess('linux')).toBe(true) + expect(canForkDaemonThroughUtilityProcess('win32')).toBe(true) + }) + + it('declines on hosts that install no port (plain-node serve)', () => { + expect(canForkDaemonThroughUtilityProcess('linux')).toBe(false) + expect(canForkDaemonThroughUtilityProcess('win32')).toBe(false) + }) +}) + +describe('forkDaemonThroughUtilityProcess', () => { + it('forks the shim entry and hands it the spawn spec over postMessage, never argv', async () => { + const child = await forkSettledChild() + expect(forkedPaths[0]).toContain('daemon-utility-launcher-shim.js') + expect(shim.posted).toEqual([{ kind: 'spawn', spec: SPEC }]) + expect(child.pid).toBe(777) + expect(child.connected).toBe(true) + }) + + it('relays daemon IPC messages, stderr, and exit through the ChildProcess surface', async () => { + const child = await forkSettledChild() + const messages: unknown[] = [] + const stderrChunks: string[] = [] + const exits: [number | null, NodeJS.Signals | null][] = [] + child.on('message', (message) => messages.push(message)) + child.stderr?.on('data', (chunk) => stderrChunks.push(chunk.toString('utf8'))) + child.on('exit', (code, signal) => exits.push([code, signal])) + + shim.emit('message', { kind: 'daemon-message', message: { type: 'ready' } }) + shim.emit('message', { kind: 'daemon-stderr', text: 'boom trace' }) + shim.emit('message', { kind: 'daemon-exit', code: 1, signal: null }) + + expect(messages).toEqual([{ type: 'ready' }]) + expect(stderrChunks).toEqual(['boom trace']) + expect(exits).toEqual([[1, null]]) + expect(child.exitCode).toBe(1) + // Nothing left to relay once the daemon is gone. + expect(shim.killed).toBe(true) + }) + + it('rejects when the shim reports a spawn failure', async () => { + const promise = forkDaemonThroughUtilityProcess(SPEC, forkFn) + shim.emit('message', { kind: 'shim-ready' }) + shim.emit('message', { kind: 'spawn-error', message: 'ENOENT' }) + await expect(promise).rejects.toThrow('ENOENT') + expect(shim.killed).toBe(true) + }) + + it('rejects when the shim dies during the handshake', async () => { + const promise = forkDaemonThroughUtilityProcess(SPEC, forkFn) + shim.emit('message', { kind: 'shim-ready' }) + shim.emit('exit', 1) + await expect(promise).rejects.toThrow('exited during the launch handshake') + }) + + it('rejects when the shim never answers', async () => { + vi.useFakeTimers() + const promise = forkDaemonThroughUtilityProcess(SPEC, forkFn) + const assertion = expect(promise).rejects.toThrow('handshake timed out') + await vi.advanceTimersByTimeAsync(10_001) + await assertion + expect(shim.killed).toBe(true) + }) + + it('surfaces an unexpected shim death after launch as a child error', async () => { + const child = await forkSettledChild() + const errors: Error[] = [] + child.on('error', (error) => errors.push(error)) + shim.emit('exit', 1) + expect(errors).toHaveLength(1) + expect(errors[0].message).toContain('before the daemon settled') + }) + + it('suppresses late daemon-error relays after release: no listener remains to catch them', async () => { + const child = await forkSettledChild() + child.disconnect() + // Would be an uncaught exception if emitted with no 'error' listener. + expect(() => + shim.emit('message', { kind: 'daemon-error', message: 'late failure' }) + ).not.toThrow() + }) + + it('disconnect releases the shim instead of killing the daemon', async () => { + const child = await forkSettledChild() + const errors: Error[] = [] + child.on('error', (error) => errors.push(error)) + child.disconnect() + expect(child.connected).toBe(false) + expect(shim.posted).toContainEqual({ kind: 'release' }) + // The shim exiting after release is the expected shutdown, not a failure. + shim.emit('exit', 0) + expect(errors).toEqual([]) + }) +}) diff --git a/src/main/daemon/daemon-utility-process-fork.ts b/src/main/daemon/daemon-utility-process-fork.ts new file mode 100644 index 00000000000..2804ca280af --- /dev/null +++ b/src/main/daemon/daemon-utility-process-fork.ts @@ -0,0 +1,255 @@ +/** + * Forks the detached terminal daemon through an Electron utility process on + * Linux and Windows so it starts with a clean descriptor/handle table. + * + * Why: Chromium descriptors in the Electron main process (the CDP listener, + * crashpad channel, mojo socketpairs, writable profile files) are inheritable + * on Linux and Windows, and a daemon forked directly from main carries them — + * and hands them to every PTY child — for its whole detached lifetime. The + * observable damage: after the app exits or restarts, the daemon lineage keeps + * the CDP port bound with no acceptor (the relaunched app comes back + * debugger-less), keeps a dead instance's crashpad handler alive, and pins + * deleted shared-memory segments. Chromium launches utility processes with an + * explicit stdio-only descriptor grant on both platforms, so a daemon forked + * from a utility-process shim inherits none of that. + * + * macOS keeps the direct fork: libuv spawns with POSIX_SPAWN_CLOEXEC_DEFAULT + * there (children already start clean), and macOS TCC attribution relies on + * the direct app→daemon fork chain (STA-3491). + */ +import { EventEmitter } from 'node:events' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import type { + DaemonShimDownMessage, + DaemonShimUpMessage, + UtilityDaemonForkSpec +} from './daemon-utility-fork-messages' + +/** + * The structural slice of ChildProcess the daemon launcher consumes. The + * direct-fork path returns a real ChildProcess, which satisfies this. + */ +export type LaunchedDaemonChild = { + pid?: number | undefined + exitCode: number | null + signalCode: NodeJS.Signals | null + connected: boolean + stderr: LaunchedDaemonStderr | null + on(event: 'message', listener: (message: unknown) => void): unknown + on(event: 'error', listener: (error: Error) => void): unknown + on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown + once(event: 'exit', listener: (code: number | null) => void): unknown + // Why any[] not never[]: mirrors EventEmitter.off, which class implements-checks compare non-bivariantly. + off(event: string, listener: (...args: any[]) => void): unknown + disconnect(): void + unref(): void +} + +export type LaunchedDaemonStderr = { + on(event: 'data', listener: (chunk: Buffer) => void): unknown + off(event: 'data', listener: (chunk: Buffer) => void): unknown + destroy(): void +} + +/** The slice of Electron's UtilityProcess this module drives. */ +export type UtilityProcessLike = { + pid?: number + postMessage(message: unknown): void + on(event: 'message', listener: (message: unknown) => void): unknown + on(event: 'spawn', listener: () => void): unknown + on(event: 'exit', listener: (code: number) => void): unknown + kill(): boolean +} + +export type UtilityProcessForkFn = ( + modulePath: string, + args?: string[], + options?: { stdio?: string; serviceName?: string } +) => UtilityProcessLike + +/** How long the shim gets to spawn and report the daemon pid. */ +const SHIM_HANDSHAKE_TIMEOUT_MS = 10_000 +/** After release, how long the shim gets to exit on its own before a kill. */ +const SHIM_RELEASE_KILL_DELAY_MS = 5_000 + +// Why a host port and not an electron import: this module sits in the daemon +// launcher's graph, which the Orca runtime must be able to load on plain Node +// (`orca serve`). The desktop installs the real utilityProcess.fork from +// src/main/host/ at bootstrap; a Node host installs nothing — its parent +// process has no Chromium descriptors, so the direct fork is already clean. +let installedUtilityProcessFork: UtilityProcessForkFn | null = null + +export function setDaemonUtilityProcessFork(fork: UtilityProcessForkFn | null): void { + installedUtilityProcessFork = fork +} + +export function canForkDaemonThroughUtilityProcess( + platform: NodeJS.Platform = process.platform +): boolean { + if (platform === 'darwin') { + return false + } + return installedUtilityProcessFork !== null +} + +function getDaemonUtilityLauncherShimPath(): string { + // Why not the app.asar.unpacked redirect daemon-entry needs: the shim runs + // under the Electron runtime, which reads asar directly. + const appPath = getAppEnvironment().getAppPath() + const directPath = join(appPath, 'daemon-utility-launcher-shim.js') + return existsSync(directPath) + ? directPath + : join(appPath, 'out', 'main', 'daemon-utility-launcher-shim.js') +} + +class UtilityForkedDaemonStderr extends EventEmitter implements LaunchedDaemonStderr { + destroy(): void { + this.removeAllListeners() + } +} + +class UtilityForkedDaemonChild extends EventEmitter implements LaunchedDaemonChild { + pid: number | undefined + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + connected = true + readonly stderr = new UtilityForkedDaemonStderr() + + private daemonExited = false + private releasedShim = false + + constructor(private readonly shim: UtilityProcessLike) { + super() + } + + handleShimMessage(message: DaemonShimUpMessage): void { + switch (message.kind) { + case 'daemon-message': + this.emit('message', message.message) + break + case 'daemon-stderr': + this.stderr.emit('data', Buffer.from(message.text, 'utf8')) + break + case 'daemon-error': + // After release the launcher has dropped its listeners; an unlistened + // 'error' emission is an uncaught exception in the main process. + if (!this.releasedShim) { + this.emit('error', new Error(message.message)) + } + break + case 'daemon-exit': + this.daemonExited = true + this.exitCode = message.code + this.signalCode = message.signal + this.emit('exit', message.code, message.signal) + // The shim has nothing left to relay. + this.shim.kill() + break + case 'shim-ready': + case 'spawned': + case 'spawn-error': + // Handshake messages; the launch promise consumes them before routing here. + break + } + } + + handleShimExit(): void { + if (this.daemonExited || this.releasedShim) { + return + } + // The relay died while the launch still depended on it. The daemon may be + // fine, but readiness/exit can no longer be observed — surface it like a + // fork error so the launcher's failure path owns cleanup by pid. + this.emit('error', new Error('Daemon utility launcher exited before the daemon settled')) + } + + disconnect(): void { + if (!this.connected) { + return + } + this.connected = false + this.releasedShim = true + const down: DaemonShimDownMessage = { kind: 'release' } + try { + this.shim.postMessage(down) + } catch { + // Shim already gone; the daemon is detached either way. + } + // Fallback if the shim ignores the release; timer must not hold the loop. + const killTimer = setTimeout(() => this.shim.kill(), SHIM_RELEASE_KILL_DELAY_MS) + killTimer.unref?.() + this.shim.on('exit', () => clearTimeout(killTimer)) + } + + unref(): void { + // The shim exits right after release and the daemon is already detached; + // there is no parent-side handle left to unref. + } +} + +export async function forkDaemonThroughUtilityProcess( + spec: UtilityDaemonForkSpec, + forkUtilityProcess?: UtilityProcessForkFn +): Promise { + const fork = forkUtilityProcess ?? installedUtilityProcessFork + if (!fork) { + throw new Error('No utility-process fork is installed on this host') + } + const shim = fork(getDaemonUtilityLauncherShimPath(), [], { + stdio: 'ignore', + serviceName: 'orca-daemon-launcher' + }) + const child = new UtilityForkedDaemonChild(shim) + + return await new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + fail(new Error('Daemon utility launcher handshake timed out')) + }, SHIM_HANDSHAKE_TIMEOUT_MS) + + function fail(error: Error): void { + if (settled) { + return + } + settled = true + clearTimeout(timer) + shim.kill() + reject(error) + } + + shim.on('message', (raw) => { + const message = raw as DaemonShimUpMessage | null + if (!message || typeof message !== 'object') { + return + } + if (message.kind === 'shim-ready') { + const down: DaemonShimDownMessage = { kind: 'spawn', spec } + shim.postMessage(down) + return + } + if (message.kind === 'spawned') { + if (!settled) { + settled = true + clearTimeout(timer) + child.pid = message.pid + resolve(child) + } + return + } + if (message.kind === 'spawn-error') { + fail(new Error(`Daemon spawn failed in utility launcher: ${message.message}`)) + return + } + child.handleShimMessage(message) + }) + shim.on('exit', () => { + if (!settled) { + fail(new Error('Daemon utility launcher exited during the launch handshake')) + return + } + child.handleShimExit() + }) + }) +} diff --git a/src/main/host/electron-daemon-utility-process-fork.ts b/src/main/host/electron-daemon-utility-process-fork.ts new file mode 100644 index 00000000000..f1a952728a1 --- /dev/null +++ b/src/main/host/electron-daemon-utility-process-fork.ts @@ -0,0 +1,14 @@ +import { utilityProcess } from 'electron' +import type { UtilityProcessForkFn } from '../daemon/daemon-utility-process-fork' + +/** + * The desktop implementation of the daemon launcher's utility-process port. + * + * Why it exists: on Linux and Windows a daemon forked directly from the Electron + * main process inherits Chromium descriptors (the CDP listener among them) for + * its whole detached lifetime. Chromium launches utility processes with a clean + * stdio-only descriptor grant, so the daemon launcher forks through one where a + * desktop host installs this. + */ +export const electronDaemonUtilityProcessFork: UtilityProcessForkFn = (modulePath, args, options) => + utilityProcess.fork(modulePath, args ? [...args] : [], options) diff --git a/src/main/index.ts b/src/main/index.ts index 94931d16803..42cdce1cb0e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,6 +27,8 @@ import { ElectronAppEnvironment } from './host/electron-app-environment' import { setPtyHostBindings } from './ipc/pty-host-bindings' import { electronRuntimeDesktopSurface } from './host/electron-runtime-desktop-surface' import { setRuntimeDesktopSurface } from './runtime/runtime-desktop-surface' +import { electronDaemonUtilityProcessFork } from './host/electron-daemon-utility-process-fork' +import { setDaemonUtilityProcessFork } from './daemon/daemon-utility-process-fork' import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-commands' import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory' import { electronHttpClient } from './host/electron-http-client' @@ -949,6 +951,10 @@ if (hasSingleInstanceLock) { // tab-create-reply channel are desktop-only. A Node host installs none and the // runtime routes notifications to paired clients instead. setRuntimeDesktopSurface(electronRuntimeDesktopSurface) + // Why: on Linux/Windows the daemon forks through a utility process so it does not + // inherit Chromium descriptors (CDP listener, crashpad channel, profile fds). A Node + // host installs nothing — its parent has no Chromium descriptors to leak. + setDaemonUtilityProcessFork(electronDaemonUtilityProcessFork) // Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser // cluster into the graph. The desktop installs it; a Node host installs none and every // browser RPC rejects, which capability filtering already tells clients about. diff --git a/src/main/startup/host-port-bootstrap-wiring.test.ts b/src/main/startup/host-port-bootstrap-wiring.test.ts index cfebf6cb0a8..ee7a4362eb7 100644 --- a/src/main/startup/host-port-bootstrap-wiring.test.ts +++ b/src/main/startup/host-port-bootstrap-wiring.test.ts @@ -24,6 +24,7 @@ describe('host port bootstrap wiring', () => { 'setSecretStore(new ElectronSecretStore())', 'setPtyHostBindings({', 'setRuntimeDesktopSurface(electronRuntimeDesktopSurface)', + 'setDaemonUtilityProcessFork(electronDaemonUtilityProcessFork)', 'setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory)', 'setDefaultProxySessionResolver(', 'setMainHttpClient(electronHttpClient)',