From a1e6b4e74ff45b5c5b515be73c00ff8e9143ff77 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 04:03:52 -0700 Subject: [PATCH] fix: isolate headless Bun daemon scope launches --- .../daemon-launched-child-spawn.test.ts | 51 ++++++++++++++++++- .../daemon/daemon-launched-child-spawn.ts | 19 ++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/main/daemon/daemon-launched-child-spawn.test.ts b/src/main/daemon/daemon-launched-child-spawn.test.ts index a3b5d0d4252..1b636261fa1 100644 --- a/src/main/daemon/daemon-launched-child-spawn.test.ts +++ b/src/main/daemon/daemon-launched-child-spawn.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { spawnDaemonChildProcess } from './daemon-launched-child-spawn' const { spawn, fork } = vi.hoisted(() => ({ spawn: vi.fn(), fork: vi.fn() })) @@ -20,7 +21,11 @@ const options = { macosLoginSessionWatch: false } -afterEach(() => vi.clearAllMocks()) +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) describe('daemon launch scope ownership', () => { it('only arms lifetime cleanup through the private scope launcher', () => { @@ -49,3 +54,47 @@ describe('daemon launch scope ownership', () => { ) }) }) + +describe('headless Bun daemon launch', () => { + function useBun(): void { + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, bun: '1.4.2' }) + vi.stubEnv('NODE_OPTIONS', '--require=untrusted.js') + vi.stubEnv('NODE_PATH', '/untrusted') + vi.stubEnv('BUN_OPTIONS', '--preload=untrusted.js') + } + + it('isolates scoped Bun launches before the daemon entry', () => { + useBun() + spawnDaemonChildProcess(options, true) + const call = spawn.mock.calls[0][0] + const executableIndex = call.args.indexOf(process.execPath) + expect(call.args.slice(executableIndex + 1, executableIndex + 5)).toEqual([ + ...bunOwnedRuntimeArgs(), + options.forkEntryPath + ]) + for (const key of ['ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS', 'NODE_PATH', 'BUN_OPTIONS']) { + expect(call.env[key]).toBeUndefined() + } + }) + + it('scrubs direct Bun forks while preserving the selected ConPTY library', () => { + useBun() + vi.stubEnv('BUN_CONPTY_LIBRARY', '/verified/conpty.dll') + spawnDaemonChildProcess(options, false) + expect(fork.mock.calls[0][0].env).toEqual( + expect.objectContaining({ + BUN_CONPTY_LIBRARY: '/verified/conpty.dll', + ORCA_USER_DATA_PATH: options.userDataPath + }) + ) + expect(fork.mock.calls[0][0].env.NODE_OPTIONS).toBeUndefined() + }) + + it('preserves an explicitly selected Node executable', () => { + useBun() + spawnDaemonChildProcess({ ...options, relocatedExecPath: '/alternate/node' }, true) + const call = spawn.mock.calls[0][0] + expect(call.args).not.toContain('--no-install') + expect(call.env.ELECTRON_RUN_AS_NODE).toBe('1') + }) +}) diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index 887241e2daa..7dbae59c330 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -1,5 +1,6 @@ import { forkProcess, type ForkSpec } from '../../shared/child-process/fork-process' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { getAppEnvironment } from '../../shared/app-environment' import { buildDurableDaemonScopeCommand } from './daemon-cgroup-scope' import { daemonLogArgs } from './daemon-launch-paths' @@ -57,13 +58,22 @@ export function spawnDaemonChildProcess( ): SpawnedProcess { const { forkEntryPath, relocatedExecPath, userDataPath, launchNonce } = options const scriptArgs = buildDaemonScriptArgs(options) + const usesBun = Boolean( + process.versions.bun && (!relocatedExecPath || relocatedExecPath === process.execPath) + ) // Why: run as plain Node so Electron's GPU/display init can't interfere with node-pty's posix_spawn of the spawn-helper. - const daemonEnv = { + const daemonEnv: NodeJS.ProcessEnv = { ...process.env, ELECTRON_RUN_AS_NODE: '1', // Why: the detached plain-Node daemon has no AppEnvironment, but shell rcfiles must live outside swept tmp. ORCA_USER_DATA_PATH: userDataPath } + if (usesBun) { + delete daemonEnv.ELECTRON_RUN_AS_NODE + delete daemonEnv.NODE_OPTIONS + delete daemonEnv.NODE_PATH + delete daemonEnv.BUN_OPTIONS + } // Why cwd: detached daemons outlive dev worktrees; userData keeps process.cwd() valid after a repo/worktree is deleted. // Why detached/stdio: detached+unref outlives Electron; stdout 'ignore' (else blocks exit), stderr 'pipe' captures startup crashes lost in v1.4.129-rc.1. const childOptions: Pick = { @@ -83,7 +93,12 @@ export function spawnDaemonChildProcess( } const scoped = buildDurableDaemonScopeCommand( relocatedExecPath ?? process.execPath, - [forkEntryPath, ...scriptArgs, '--fresh-daemon-scope'], + [ + ...(usesBun ? bunOwnedRuntimeArgs() : []), + forkEntryPath, + ...scriptArgs, + '--fresh-daemon-scope' + ], launchNonce, daemonEnv )