diff --git a/config/scripts/bun-profile-test-paths.mjs b/config/scripts/bun-profile-test-paths.mjs index 13b17f89b060..8a0d6bd05df8 100644 --- a/config/scripts/bun-profile-test-paths.mjs +++ b/config/scripts/bun-profile-test-paths.mjs @@ -12,6 +12,7 @@ export function bunProfileTestPaths({ artifact = false } = {}) { 'src/main/daemon/headless-emulator-fidelity.fuzz.test.ts', ...(artifact ? [ + 'src/relay/windows-detached-launch.integration.test.ts', 'src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts', 'src/main/pty/posix-pty-process-groups.integration.test.ts', 'src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts', diff --git a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts index d6f13f407dd7..7fe1140306b3 100644 --- a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts +++ b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts @@ -159,7 +159,7 @@ describe('deployAndLaunchRelay on Windows remotes', () => { it('launches Windows remotes via a named pipe endpoint', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - vi.mocked(ensureRemoteRelayBunRuntime).mockResolvedValue('C:/Program Files/nodejs/node.exe') + vi.mocked(ensureRemoteRelayBunRuntime).mockResolvedValue('C:/Program Files/Orca/bun.exe') mockExecCommand .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe @@ -167,7 +167,7 @@ describe('deployAndLaunchRelay on Windows remotes', () => { .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe .mockResolvedValueOnce('') // no persisted active pipe .mockResolvedValueOnce('WAITING') // named pipe probe - .mockResolvedValueOnce('') // WMI relay launch + .mockResolvedValueOnce('') // detached relay launch .mockResolvedValueOnce('READY') // named pipe poll .mockResolvedValueOnce('') // persist active pipe marker @@ -185,16 +185,20 @@ describe('deployAndLaunchRelay on Windows remotes', () => { const nativeProbe = decodedScripts.find((script) => script.includes('ORCA-NATIVE-DEPS-OK')) expect(nativeProbe).toContain('./parcel-watcher.node') expect(nativeProbe).not.toContain('require("@parcel/watcher")') - const launchScript = decodedScripts.find((script) => script.includes('Invoke-CimMethod')) ?? '' + const launchScript = decodedScripts.find((script) => script.includes('--spawn-detached')) ?? '' expect(launchScript).toContain( - '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/relay.js"' + "'C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/relay.js'" ) expect(launchScript).toContain( - '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/agent-hooks/orca-relay-' + "'C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/agent-hooks/orca-relay-" ) expect(launchScript).toContain('--endpoint-dir') + expect(launchScript).toContain("& 'C:/Program Files/Orca/bun.exe'") + expect(launchScript).toContain('--launch-error-file') + expect(launchScript).not.toMatch(/Invoke-CimMethod|cmd\.exe|\$env:PATH/) + expect(launchScript).toContain('Set-Location') expect(launchScript).toContain( - '--ripgrep-path "C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe"' + "'--ripgrep-path' 'C:/Users/me user/.orca-remote/ripgrep/c0ffee0123456789-win32-x64/rg.exe'" ) expect(launchScript).not.toContain('--pty-source-credit-v1') expect(launchScript).not.toContain('.pty-source-credit-policy') @@ -205,7 +209,7 @@ describe('deployAndLaunchRelay on Windows remotes', () => { const script = decodePowerShellCommand(command) return ( script?.includes('.windows-active-pipe') || - script?.includes('Invoke-CimMethod') || + script?.includes('--spawn-detached') || script?.includes('deadline=Date.now()') ) }) @@ -220,7 +224,7 @@ describe('deployAndLaunchRelay on Windows remotes', () => { it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - vi.mocked(ensureRemoteRelayBunRuntime).mockResolvedValue('C:/Program Files/nodejs/node.exe') + vi.mocked(ensureRemoteRelayBunRuntime).mockResolvedValue('C:/Program Files/Orca/bun.exe') vi.mocked(waitForSentinel) .mockRejectedValueOnce(new Error('stale daemon handshake failed')) .mockResolvedValueOnce({ @@ -236,7 +240,7 @@ describe('deployAndLaunchRelay on Windows remotes', () => { .mockResolvedValueOnce('') // no persisted active pipe yet .mockResolvedValueOnce('READY') // existing named pipe probe .mockResolvedValueOnce('WAITING') // deterministic fallback pipe is not already running - .mockResolvedValueOnce('') // WMI relay launch on fallback pipe + .mockResolvedValueOnce('') // detached relay launch on fallback pipe .mockResolvedValueOnce('READY') // fallback pipe poll .mockResolvedValueOnce('') // persist fallback active pipe marker @@ -256,7 +260,7 @@ describe('deployAndLaunchRelay on Windows remotes', () => { const launchScript = mockExecCommand.mock.calls .map(([, command]) => decodePowerShellCommand(command)) - .find((script) => script?.includes('Invoke-CimMethod')) ?? '' + .find((script) => script?.includes('--spawn-detached')) ?? '' expect(launchScript).toContain(fallbackPipe) expect(launchScript).not.toContain(primaryPipe) @@ -274,7 +278,7 @@ describe('deployAndLaunchRelay on Windows remotes', () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' - vi.mocked(ensureRemoteRelayBunRuntime).mockResolvedValue('C:/Program Files/nodejs/node.exe') + vi.mocked(ensureRemoteRelayBunRuntime).mockResolvedValue('C:/Program Files/Orca/bun.exe') mockExecCommand .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe @@ -295,14 +299,14 @@ describe('deployAndLaunchRelay on Windows remotes', () => { const decodedExecScripts = mockExecCommand.mock.calls .map(([, command]) => decodePowerShellCommand(command)) .filter((script): script is string => script !== null) - expect(decodedExecScripts.some((script) => script.includes('Invoke-CimMethod'))).toBe(false) + expect(decodedExecScripts.some((script) => script.includes('--spawn-detached'))).toBe(false) }) it('scopes persisted Windows active pipe markers by relay target', async () => { const connA = makeMockConnection() const connB = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) - vi.mocked(ensureRemoteRelayBunRuntime).mockResolvedValue('C:/Program Files/nodejs/node.exe') + vi.mocked(ensureRemoteRelayBunRuntime).mockResolvedValue('C:/Program Files/Orca/bun.exe') mockExecCommand .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe A .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 6316d0bf7f6f..ae37470dae12 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -50,7 +50,11 @@ import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' -import { commandWithNodePath, readRemoteHomeCommand } from './ssh-remote-commands' +import { + commandInRemoteDirectory, + commandWithNodePath, + readRemoteHomeCommand +} from './ssh-remote-commands' import { cleanupOwnedRelayUploadStageCommand, parseReservedRelayUploadStage, @@ -1481,39 +1485,30 @@ function windowsRelayLaunchCommand( ripgrepPath?: string ): string { const relayScript = joinRemotePath(hostPlatform, remoteDir, 'relay.js') - // Why: Windows sshd kills the exec channel's process tree on close; WMI re-parents the detached relay to survive. - const quoted = (value: string): string => `"${value.replace(/"/g, '\\"')}"` - const relayCommandLine = [ - quoted(nodePath), + const args = [ '--no-env-file', '--config=NUL', '--no-install', - quoted(relayScript), - '--detached', + relayScript, + '--spawn-detached', '--grace-time', String(graceTime), '--sock-path', - quoted(sockPath), + sockPath, '--credential-file', - quoted(credentialFile), + credentialFile, '--endpoint-dir', - quoted(endpointDir), - // Why: --log-file owns rotation; shell redirects still capture pre-JS boot/crash output. + endpointDir, '--log-file', - quoted(logFile), - ...(ripgrepPath ? ['--ripgrep-path', quoted(ripgrepPath)] : []), - `1>${quoted(logFile)}`, - `2>${quoted(errFile)}` - ].join(' ') - const wmiCommandLine = `cmd.exe /d /s /c "${relayCommandLine}"` - return commandWithNodePath( + logFile, + '--launch-error-file', + errFile, + ...(ripgrepPath ? ['--ripgrep-path', ripgrepPath] : []) + ] + return commandInRemoteDirectory( hostPlatform, - nodePath, remoteDir, - [ - `$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(wmiCommandLine)}; CurrentDirectory = ${powerShellLiteral(remoteDir)} }`, - `if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }` - ].join('; ') + `& ${powerShellLiteral(nodePath)} ${args.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { throw "Bun detached launch failed with exit $LASTEXITCODE" }` ) } diff --git a/src/relay/relay-launch-options.test.ts b/src/relay/relay-launch-options.test.ts index e16d3cad783d..4fcf3935f338 100644 --- a/src/relay/relay-launch-options.test.ts +++ b/src/relay/relay-launch-options.test.ts @@ -3,7 +3,11 @@ import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { parseRelayLaunchOptions, readRelayEndpointCredential } from './relay-launch-options' +import { + detachedRelayArguments, + parseRelayLaunchOptions, + readRelayEndpointCredential +} from './relay-launch-options' describe('relay launch options', () => { const temporaryDirectories: string[] = [] @@ -39,6 +43,9 @@ describe('relay launch options', () => { connectMode: true, detached: true, cliMode: false, + spawnDetachedMode: false, + launchErrorFile: undefined, + ripgrepPath: undefined, sockPath: 'relay-endpoint', endpointDir: 'hooks', logFile: 'relay.log', @@ -46,6 +53,73 @@ describe('relay launch options', () => { }) }) + it('forwards launch values as argv without recursive launch or shell expansion', () => { + const options = parseRelayLaunchOptions([ + 'bun', + 'relay.js', + '--spawn-detached', + '--grace-time', + '0', + '--sock-path', + '--spawn-detached', + '--credential-file', + 'C:\\a & %USER%\\credential', + '--log-file', + 'C:\\logs with spaces\\out.log', + '--launch-error-file', + 'err.log' + ]) + const args = detachedRelayArguments('C:\\relay root\\relay.js', options) + expect(args).toEqual([ + '--no-env-file', + '--config=NUL', + '--no-install', + 'C:\\relay root\\relay.js', + '--detached', + '--grace-time', + '0', + '--sock-path', + '--spawn-detached', + '--log-file', + 'C:\\logs with spaces\\out.log', + '--credential-file', + 'C:\\a & %USER%\\credential' + ]) + const reparsed = parseRelayLaunchOptions(['bun', ...args.slice(3)]) + expect(reparsed.spawnDetachedMode).toBe(false) + expect(reparsed.sockPath).toBe('--spawn-detached') + expect(reparsed.launchErrorFile).toBeUndefined() + }) + + it('preserves every daemon option across the detached launcher', () => { + const options = parseRelayLaunchOptions([ + 'bun', + 'relay.js', + '--spawn-detached', + '--grace-time', + '123', + '--sock-path', + 'pipe-name', + '--endpoint-dir', + 'C:/hooks & tools', + '--credential-file', + 'C:/秘密/credential', + '--log-file', + 'C:/logs/relay.log', + '--launch-error-file', + 'C:/logs/relay.err.log', + '--ripgrep-path', + 'C:/tools/rg.exe' + ]) + const args = detachedRelayArguments('C:/relay/relay.js', options) + expect(parseRelayLaunchOptions(['bun', ...args.slice(3)])).toEqual({ + ...options, + detached: true, + spawnDetachedMode: false, + launchErrorFile: undefined + }) + }) + it('reads the bundled ripgrep path, which older launch commands omit', () => { expect( parseRelayLaunchOptions([ diff --git a/src/relay/relay-launch-options.ts b/src/relay/relay-launch-options.ts index f9e057ce55f7..c4c48b4b6bd3 100644 --- a/src/relay/relay-launch-options.ts +++ b/src/relay/relay-launch-options.ts @@ -18,6 +18,8 @@ export type RelayLaunchOptions = { connectMode: boolean detached: boolean cliMode: boolean + spawnDetachedMode?: boolean + launchErrorFile?: string sockPath: string endpointDir?: string logFile?: string @@ -31,6 +33,8 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { let connectMode = false let detached = false let cliMode = false + let spawnDetachedMode = false + let launchErrorFile: string | undefined let sockPath = '' let endpointDir: string | undefined let logFile: string | undefined @@ -48,6 +52,10 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { connectMode = true } else if (argv[i] === '--orca-cli') { cliMode = true + } else if (argv[i] === '--spawn-detached') { + spawnDetachedMode = true + } else if (argv[i] === '--launch-error-file' && argv[i + 1]) { + launchErrorFile = argv[++i] } else if (argv[i] === '--detached') { detached = true } else if (argv[i] === '--sock-path' && argv[i + 1]) { @@ -75,6 +83,8 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { connectMode, detached, cliMode, + spawnDetachedMode, + launchErrorFile, sockPath, endpointDir, logFile, @@ -83,6 +93,24 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { } } +export function detachedRelayArguments(entrypoint: string, options: RelayLaunchOptions): string[] { + return [ + '--no-env-file', + '--config=NUL', + '--no-install', + entrypoint, + '--detached', + '--grace-time', + String(options.graceTimeMs / 1000), + '--sock-path', + options.sockPath, + ...(options.endpointDir ? ['--endpoint-dir', options.endpointDir] : []), + ...(options.logFile ? ['--log-file', options.logFile] : []), + ...(options.credentialFile ? ['--credential-file', options.credentialFile] : []), + ...(options.ripgrepPath ? ['--ripgrep-path', options.ripgrepPath] : []) + ] +} + export function readRelayEndpointCredential( credentialFile: string | undefined ): string | undefined { diff --git a/src/relay/relay.ts b/src/relay/relay.ts index c245eb32e7d3..8919a87d8b4f 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -1,12 +1,16 @@ - // Orca Relay — remote-host daemon and reconnect bridge entry point. -import { parseRelayLaunchOptions, readRelayEndpointCredential } from './relay-launch-options' +import { + detachedRelayArguments, + parseRelayLaunchOptions, + readRelayEndpointCredential +} from './relay-launch-options' import { runRelayConnectChannel } from './relay-connect-channel' import { runRelayOrcaCliChannel } from './relay-orca-cli-channel' import { runRelayDaemon } from './relay-daemon' import { relayLogLine } from './relay-diagnostic-log' import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' +import { launchDetachedWindowsRelay } from './windows-detached-launch' async function main(): Promise { const options = parseRelayLaunchOptions(process.argv) @@ -23,6 +27,20 @@ async function main(): Promise { ) return } + if (options.spawnDetachedMode) { + if (!options.logFile || !options.launchErrorFile) { + throw new Error('Detached relay launch requires stdout and stderr paths') + } + const pid = launchDetachedWindowsRelay({ + executable: process.execPath, + args: detachedRelayArguments(process.argv[1], options), + cwd: process.cwd(), + stdoutPath: options.logFile, + stderrPath: options.launchErrorFile + }) + process.stdout.write(`launched ${pid}\n`) + return + } configureRelayBundledRipgrep(options.ripgrepPath) // Why no read here: the daemon publishes its credential itself, after it owns the socket. await runRelayDaemon(options) diff --git a/src/relay/windows-detached-launch-fixture.ts b/src/relay/windows-detached-launch-fixture.ts new file mode 100644 index 000000000000..b8b86b32bf8c --- /dev/null +++ b/src/relay/windows-detached-launch-fixture.ts @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { launchDetachedWindowsRelay } from './windows-detached-launch' + +type Pointer = number | bigint +type NativeApi = { + CreateJobObjectW(security: null, name: Pointer): Pointer + SetInformationJobObject(job: Pointer, kind: number, data: Pointer, size: number): number + AssignProcessToJobObject(job: Pointer, process: Pointer): number + GetCurrentProcess(): Pointer + IsProcessInJob(process: Pointer, job: Pointer | null, result: Pointer): number + OpenJobObjectW(access: number, inherit: number, name: Pointer): Pointer + CreateFileW( + path: Pointer, + access: number, + share: number, + security: Pointer, + creation: number, + flags: number, + template: null + ): Pointer + GetFinalPathNameByHandleW(handle: Pointer, buffer: Pointer, size: number, flags: number): number + GetProcessTimes( + process: Pointer, + creation: Pointer, + exit: Pointer, + kernel: Pointer, + user: Pointer + ): number + OpenProcess(access: number, inherit: number, pid: number): Pointer + WaitForSingleObject(handle: Pointer, timeout: number): number + TerminateProcess(handle: Pointer, code: number): number + GetHandleInformation(handle: Pointer, flags: Pointer): number + CloseHandle(handle: Pointer): number +} +type Ffi = { + ptr(value: ArrayBufferView): Pointer + dlopen( + name: string, + symbols: Record + ): { symbols: T; close(): void } +} +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture runs only on the explicitly selected Bun executable. +const ffi = createRequire(__filename)('bun:ffi') as Ffi +const library = ffi.dlopen('kernel32.dll', { + CreateJobObjectW: { args: ['ptr', 'ptr'], returns: 'u64' }, + SetInformationJobObject: { args: ['u64', 'u32', 'ptr', 'u32'], returns: 'i32' }, + AssignProcessToJobObject: { args: ['u64', 'u64'], returns: 'i32' }, + GetCurrentProcess: { args: [], returns: 'u64' }, + IsProcessInJob: { args: ['u64', 'u64', 'ptr'], returns: 'i32' }, + OpenJobObjectW: { args: ['u32', 'i32', 'ptr'], returns: 'u64' }, + CreateFileW: { args: ['ptr', 'u32', 'u32', 'ptr', 'u32', 'u32', 'ptr'], returns: 'u64' }, + GetFinalPathNameByHandleW: { args: ['u64', 'ptr', 'u32', 'u32'], returns: 'u32' }, + GetProcessTimes: { args: ['u64', 'ptr', 'ptr', 'ptr', 'ptr'], returns: 'i32' }, + OpenProcess: { args: ['u32', 'i32', 'u32'], returns: 'u64' }, + WaitForSingleObject: { args: ['u64', 'u32'], returns: 'u32' }, + TerminateProcess: { args: ['u64', 'u32'], returns: 'i32' }, + GetHandleInformation: { args: ['u64', 'ptr'], returns: 'i32' }, + CloseHandle: { args: ['u64'], returns: 'i32' } +}) +const api = library.symbols +const objectLibrary = ffi.dlopen<{ + NtQueryObject( + handle: Pointer, + kind: number, + buffer: Pointer, + size: number, + returned: Pointer + ): number +}>('ntdll.dll', { + NtQueryObject: { args: ['u64', 'u32', 'ptr', 'u32', 'ptr'], returns: 'u32' } +}) +function markerMatches(handle: Pointer): boolean { + const buffer = new Uint8Array(4096) + const returned = new Uint32Array(1) + const status = objectLibrary.symbols.NtQueryObject( + handle, + 2, + ffi.ptr(buffer), + buffer.length, + ffi.ptr(returned) + ) + if (status === 0xc0000008) { + return false + } + assert.equal(status, 0, 'unable to identify possible inherited marker handle') + const view = new DataView(buffer.buffer) + const length = view.getUint16(0, true) + const offset = Number(view.getBigUint64(8, true) - BigInt(ffi.ptr(buffer))) + assert(Number.isSafeInteger(offset) && offset >= 0 && offset + length <= buffer.length) + const type = Buffer.from(buffer.buffer, offset, length).toString('utf16le') + if (type !== 'File') { + assert(type.length > 0, 'unidentified native object type') + return false + } + const path = new Uint16Array(32768) + const size = api.GetFinalPathNameByHandleW(handle, ffi.ptr(path), path.length, 0) + assert(size > 0 && size < path.length, 'unable to identify possible inherited file') + return Buffer.from(path.buffer, 0, size * 2) + .toString('utf16le') + .endsWith('inheritable-marker.txt') +} +function utf16(value: string): Uint16Array { + const output = new Uint16Array(value.length + 1) + for (let i = 0; i < value.length; i++) { + output[i] = value.charCodeAt(i) + } + return output +} +function identity(handle: Pointer): string { + const times = Array.from({ length: 4 }, () => new BigUint64Array(1)) + assert( + api.GetProcessTimes( + handle, + ffi.ptr(times[0]), + ffi.ptr(times[1]), + ffi.ptr(times[2]), + ffi.ptr(times[3]) + ) + ) + return times[0][0].toString() +} +function member(job: Pointer): boolean { + const answer = new Uint32Array(1) + assert(api.IsProcessInJob(api.GetCurrentProcess(), job, ffi.ptr(answer))) + return answer[0] !== 0 +} +function publish(path: string, value: string): void { + writeFileSync(`${path}.pending`, value) + renameSync(`${path}.pending`, path) +} +async function waitFor(path: string): Promise { + const deadline = performance.now() + 8_000 + while (!existsSync(path)) { + assert(performance.now() < deadline, `timed out waiting for ${path}`) + await delay(25) + } +} +async function main(): Promise { + const [mode, directory, jobName, markerHandle, ...args] = process.argv.slice(2) + assert(mode && directory) + // Every detached child exits independently even if the test runner is killed. + const watchdog = setTimeout(() => process.exit(2), 15_000) + try { + if (mode === 'inspect') { + const receipt = JSON.parse(readFileSync(join(directory, 'owned-child.json'), 'utf8')) + assert(Number.isInteger(receipt.pid) && typeof receipt.creation === 'string') + const handle = api.OpenProcess(0x100000 | 0x1000 | 1, 0, receipt.pid) + assert(handle, 'cleanup unverifiable: cannot open owned process; preserve evidence') + try { + if (identity(handle) !== receipt.creation) { + return + } + writeFileSync(join(directory, 'stop'), '') + const status = api.WaitForSingleObject(handle, 10_000) + if (status === 258) { + assert(api.TerminateProcess(handle, 2), 'owned child could not be terminated') + assert.equal(api.WaitForSingleObject(handle, 3_000), 0, 'owned child did not terminate') + throw new Error('Owned child required forced cleanup; preserve evidence') + } + assert.equal(status, 0, 'owned child exit is unverifiable') + } finally { + api.CloseHandle(handle) + } + return + } + assert(jobName) + const name = utf16(jobName) + if (mode === 'child') { + assert(markerHandle) + const job = api.OpenJobObjectW(4, 0, ffi.ptr(name)) + assert(job, 'parent job must still exist while child records membership') + let childInJob: boolean + try { + childInJob = member(job) + } finally { + api.CloseHandle(job) + } + const markerInherited = markerMatches(BigInt(markerHandle)) + publish( + join(directory, 'child.json'), + JSON.stringify({ + pid: process.pid, + creation: identity(api.GetCurrentProcess()), + args, + childInJob, + markerInherited + }) + ) + process.stdout.write('child stdout: 雪 🐳\n') + process.stderr.write('child stderr: λ\n') + const deadline = performance.now() + 10_000 + let tick = 0 + while (!existsSync(join(directory, 'stop')) && performance.now() < deadline) { + publish(join(directory, 'tick'), String(++tick)) + if ( + existsSync(join(directory, 'rotated')) && + !existsSync(join(directory, 'rotation-written')) + ) { + process.stdout.write('after rotation stdout\n') + process.stderr.write('after rotation stderr\n') + writeFileSync(join(directory, 'rotation-written'), '') + } + await delay(50) + } + return + } + assert.equal(mode, 'launch') + const job = api.CreateJobObjectW(null, ffi.ptr(name)) + assert(job) + const limits = new Uint8Array(144) + new DataView(limits.buffer).setUint32(16, 0x800 | 0x2000, true) + assert(api.SetInformationJobObject(job, 9, ffi.ptr(limits), limits.length)) + assert(api.AssignProcessToJobObject(job, api.GetCurrentProcess())) + assert(member(job), 'launcher must actually belong to the kill-on-close job') + const security = new Uint8Array(24) + const view = new DataView(security.buffer) + view.setUint32(0, 24, true) + view.setUint32(16, 1, true) + const markerPath = utf16(join(directory, 'inheritable-marker.txt')) + const marker = api.CreateFileW( + ffi.ptr(markerPath), + 0x40000000, + 7, + ffi.ptr(security), + 4, + 0x80, + null + ) + assert(marker && BigInt.asUintN(64, BigInt(marker)) !== 0xffffffffffffffffn) + const markerFlags = new Uint32Array(1) + assert(api.GetHandleInformation(marker, ffi.ptr(markerFlags))) + assert.equal(markerFlags[0] & 1, 1, 'marker handle must be inheritable') + assert(markerMatches(marker), 'marker identification positive control failed') + const pid = launchDetachedWindowsRelay({ + executable: process.execPath, + args: [__filename, 'child', directory, jobName, String(marker), ...args], + cwd: directory, + stdoutPath: join(directory, 'stdout.log'), + stderrPath: join(directory, 'stderr.log') + }) + const childHandle = api.OpenProcess(0x100000 | 0x1000, 0, pid) + assert(childHandle, 'unable to capture detached child identity') + try { + publish( + join(directory, 'owned-child.json'), + JSON.stringify({ pid, creation: identity(childHandle) }) + ) + } finally { + api.CloseHandle(childHandle) + } + await waitFor(join(directory, 'child.json')) + writeFileSync(join(directory, 'launch.json'), JSON.stringify({ pid, parentInJob: member(job) })) + api.CloseHandle(marker) + // Process exit closes our last job handle: any child that failed breakaway dies with us. + process.exit(0) + } finally { + clearTimeout(watchdog) + objectLibrary.close() + library.close() + } +} +void main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/src/relay/windows-detached-launch.integration.test.ts b/src/relay/windows-detached-launch.integration.test.ts new file mode 100644 index 000000000000..c821df843ad4 --- /dev/null +++ b/src/relay/windows-detached-launch.integration.test.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto' +import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { build } from 'esbuild' +import { expect, it } from 'vitest' +import { runProcess } from '../shared/child-process/run-process' + +const runtime = process.env.BUN_EXECUTABLE +async function waitFor(check: () => Promise): Promise { + const deadline = performance.now() + 8_000 + while (performance.now() < deadline) { + if (await check()) { + return + } + await delay(50) + } + throw new Error('Detached launch evidence did not arrive before its deadline') +} +async function contents(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return '' + } + throw error + } +} +it.skipIf(process.platform !== 'win32')( + 'restricts handles, preserves argv and logs, and survives its owned SSH-style job', + async () => { + if (!runtime) { + throw new Error('BUN_EXECUTABLE is required') + } + const directory = await mkdtemp(join(tmpdir(), 'orca-detached-launch-')) + const entry = join(directory, 'launch-fixture.cjs') + const args = [ + '', + 'space value', + 'tab\tvalue', + 'quote"value', + 'trailing\\', + 'space trailing\\', + '\\"', + '雪 🐳 λ', + '& | < > %PATH% !name! ^', + 'line\nbreak' + ] + const execute = (fixtureArgs: string[]) => + runProcess({ + program: runtime, + args: [entry, ...fixtureArgs], + cwd: directory, + env: { ORCA_BACKGROUND_LAUNCH: '1', BUN_OPTIONS: '', NODE_OPTIONS: '', NODE_PATH: '' }, + timeoutMs: 20_000 + }) + let launched = false + let cleaned = false + try { + await build({ + entryPoints: [join(__dirname, 'windows-detached-launch-fixture.ts')], + outfile: entry, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'es2024', + external: ['bun:ffi'] + }) + await writeFile(join(directory, 'stdout.log'), 'existing stdout\n') + await writeFile(join(directory, 'stderr.log'), 'existing stderr\n') + launched = true + const result = await execute([ + 'launch', + directory, + `Local\\orca-launch-${randomUUID()}`, + '-', + ...args + ]) + expect(result.timedOut, result.stderr).toBe(false) + // Closing a kill-on-close job can alter the launcher's exit status; its durable receipt is authoritative. + const launch = JSON.parse(await readFile(join(directory, 'launch.json'), 'utf8')) + const child = JSON.parse(await readFile(join(directory, 'child.json'), 'utf8')) + expect(launch).toEqual({ pid: child.pid, parentInJob: true }) + expect(child).toMatchObject({ args, childInJob: false, markerInherited: false }) + const owned = JSON.parse(await readFile(join(directory, 'owned-child.json'), 'utf8')) + expect(owned).toEqual({ pid: child.pid, creation: child.creation }) + expect(child.creation).toMatch(/^\d+$/) + const first = Number(await contents(join(directory, 'tick'))) + await waitFor(async () => Number(await contents(join(directory, 'tick'))) > first + 2) + await waitFor( + async () => + (await contents(join(directory, 'stdout.log'))).includes('child stdout: 雪 🐳\n') && + (await contents(join(directory, 'stderr.log'))).includes('child stderr: λ\n') + ) + expect(await contents(join(directory, 'stdout.log'))).toBe( + 'existing stdout\nchild stdout: 雪 🐳\n' + ) + expect(await contents(join(directory, 'stderr.log'))).toBe( + 'existing stderr\nchild stderr: λ\n' + ) + await rename(join(directory, 'stdout.log'), join(directory, 'stdout.old')) + await rename(join(directory, 'stderr.log'), join(directory, 'stderr.old')) + await writeFile(join(directory, 'stdout.log'), 'replacement stdout\n') + await writeFile(join(directory, 'stderr.log'), 'replacement stderr\n') + await writeFile(join(directory, 'rotated'), '') + await waitFor( + async () => + (await contents(join(directory, 'stdout.old'))).endsWith('after rotation stdout\n') && + (await contents(join(directory, 'stderr.old'))).endsWith('after rotation stderr\n') + ) + expect(await contents(join(directory, 'stdout.log'))).toBe('replacement stdout\n') + expect(await contents(join(directory, 'stderr.log'))).toBe('replacement stderr\n') + } finally { + if (launched) { + // Cleanup opens a creation-time-matched handle before asking the child to exit. + if (await contents(join(directory, 'owned-child.json'))) { + const cleanup = await execute(['inspect', directory]) + expect(cleanup.timedOut, `${cleanup.stderr}; evidence: ${directory}`).toBe(false) + expect(cleanup.code, `${cleanup.stderr}; evidence: ${directory}`).toBe(0) + cleaned = true + } else { + await writeFile(join(directory, 'stop'), '') + expect.fail(`Cleanup unverifiable without owned child identity; evidence: ${directory}`) + } + } + if (!launched || cleaned) { + await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + } + } + }, + 60_000 +) diff --git a/src/relay/windows-detached-launch.test.ts b/src/relay/windows-detached-launch.test.ts new file mode 100644 index 000000000000..149b688e4d0d --- /dev/null +++ b/src/relay/windows-detached-launch.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const native = vi.hoisted(() => ({ load: vi.fn() })) +vi.mock('node:module', () => ({ createRequire: () => native.load })) +import { launchDetachedWindowsRelay } from './windows-detached-launch' + +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +const arch = Object.getOwnPropertyDescriptor(process, 'arch')! +beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + Object.defineProperty(process, 'arch', { value: 'arm64' }) +}) +afterEach(() => { + Object.defineProperty(process, 'platform', platform) + Object.defineProperty(process, 'arch', arch) + vi.clearAllMocks() +}) + +function fixture(failAt = '') { + const pointers = new Map() + function ptr(view: ArrayBufferView): number { + const pointer = pointers.size + 1 + pointers.set(pointer, view) + return pointer + } + function view(pointer: number | bigint): DataView { + const bytes = pointers.get(Number(pointer)) + if (!bytes) { + throw new Error('Unknown pointer') + } + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + } + function text(pointer: number): string { + const bytes = pointers.get(pointer) + if (!bytes) { + throw new Error('Unknown pointer') + } + return new TextDecoder('utf-16le').decode(bytes).replace(/\0$/, '') + } + const files: string[] = [] + const api = { + CreateFileW: vi.fn((path: number, _access: number, share: number, security: number) => { + expect(view(security).getUint32(0, true)).toBe(24) + expect(view(security).getUint32(16, true)).toBe(1) + expect(share).toBe(7) + files.push(text(path)) + return failAt === `file-${files.length}` ? 0xffff_ffff_ffff_ffffn : BigInt(100 + files.length) + }), + InitializeProcThreadAttributeList: vi.fn( + (list: number | null, count: number, _flags: number, size: number) => { + expect(count).toBe(1) + view(size).setBigUint64(0, 64n, true) + return list === null || failAt === 'initialize' ? 0 : 1 + } + ), + UpdateProcThreadAttribute: vi.fn( + (_list: number, _flags: number, attribute: number, handles: number, size: number) => { + expect(attribute).toBe(0x00020002) + expect(size).toBe(24) + expect([0, 8, 16].map((offset) => view(handles).getBigUint64(offset, true))).toEqual([ + 101n, + 102n, + 103n + ]) + return failAt === 'allowlist' ? 0 : 1 + } + ), + DeleteProcThreadAttributeList: vi.fn(), + CreateProcessW: vi.fn( + ( + application: number, + command: number, + _process: null, + _thread: null, + inherit: number, + flags: number, + environment: null, + cwd: number, + startup: number, + result: number + ) => { + expect(text(application)).toBe('C:\\owned runtime\\bun.exe') + expect(text(command)).toContain('"%USER% & name"') + expect(text(cwd)).toBe('C:\\profile & space') + expect(inherit).toBe(1) + expect(flags).toBe(0x09080000) + expect(environment).toBeNull() + expect(view(startup).getUint32(0, true)).toBe(112) + expect(view(startup).getUint32(60, true)).toBe(0x100) + expect([80, 88, 96].map((offset) => view(startup).getBigUint64(offset, true))).toEqual([ + 101n, + 102n, + 103n + ]) + expect(view(startup).getBigUint64(104, true)).not.toBe(0n) + if (failAt === 'spawn') { + return 0 + } + view(result).setBigUint64(0, 201n, true) + view(result).setBigUint64(8, 202n, true) + view(result).setUint32(16, 42, true) + return 1 + } + ), + GetLastError: vi.fn(() => 5), + CloseHandle: vi.fn() + } + const close = vi.fn() + native.load.mockReturnValue({ ptr, dlopen: () => ({ symbols: api, close }) }) + return { api, files, close } +} +const options = { + executable: 'C:\\owned runtime\\bun.exe', + args: ['%USER% & name', 'tail\\'], + cwd: 'C:\\profile & space', + stdoutPath: 'C:\\stdout.log', + stderrPath: 'C:\\stderr.log' +} +describe('Windows relay job breakaway', () => { + it('uses direct argv and an explicit standard-stream allowlist, then closes parent handles', () => { + const { api, files, close } = fixture() + expect(launchDetachedWindowsRelay(options)).toBe(42) + expect(files).toEqual(['NUL', options.stdoutPath, options.stderrPath]) + expect(api.CloseHandle.mock.calls.flat()).toEqual([202n, 201n, 103n, 102n, 101n]) + expect(api.DeleteProcThreadAttributeList).toHaveBeenCalledOnce() + expect(close).toHaveBeenCalledOnce() + }) + it.each(['file-1', 'file-2', 'file-3', 'initialize', 'allowlist', 'spawn'])( + 'closes only acquired handles after %s fails', + (failAt) => { + const { api, close } = fixture(failAt) + expect(() => launchDetachedWindowsRelay(options)).toThrow('Win32 last-error hint 5') + const opened = failAt.startsWith('file-') ? Number(failAt.slice(5)) - 1 : 3 + expect(api.CloseHandle.mock.calls.flat()).toEqual( + [101n, 102n, 103n].slice(0, opened).toReversed() + ) + expect(api.DeleteProcThreadAttributeList).toHaveBeenCalledTimes( + ['allowlist', 'spawn'].includes(failAt) ? 1 : 0 + ) + expect(close).toHaveBeenCalledOnce() + if (failAt !== 'spawn') { + expect(api.CreateProcessW).not.toHaveBeenCalled() + } + } + ) + it('refuses NUL truncation and oversized commands before opening files', () => { + const { api, close } = fixture() + for (const args of [['bad\0argument'], ['a'.repeat(32768)]]) { + expect(() => launchDetachedWindowsRelay({ ...options, args })).toThrow() + } + expect(api.CreateFileW).not.toHaveBeenCalled() + expect(close).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/relay/windows-detached-launch.ts b/src/relay/windows-detached-launch.ts new file mode 100644 index 000000000000..704be696f000 --- /dev/null +++ b/src/relay/windows-detached-launch.ts @@ -0,0 +1,209 @@ +import { createRequire } from 'node:module' +import { quoteWindowsArgument } from '../shared/child-process/windows-command-line' + +type Pointer = number | bigint +type BunFfi = { + dlopen( + name: string, + symbols: Record + ): { + symbols: T + close(): void + } + ptr(view: ArrayBufferView): Pointer +} +type Kernel32 = { + CreateFileW( + path: Pointer, + access: number, + share: number, + security: Pointer, + creation: number, + flags: number, + template: null + ): Pointer + InitializeProcThreadAttributeList( + list: Pointer | null, + count: number, + flags: number, + size: Pointer + ): number + UpdateProcThreadAttribute( + list: Pointer, + flags: number, + attribute: number, + value: Pointer, + size: number, + previous: null, + returnedSize: null + ): number + DeleteProcThreadAttributeList(list: Pointer): void + CreateProcessW( + application: Pointer, + command: Pointer, + processSecurity: null, + threadSecurity: null, + inheritHandles: number, + flags: number, + environment: null, + cwd: Pointer, + startup: Pointer, + result: Pointer + ): number + GetLastError(): number + CloseHandle(handle: Pointer): number +} + +export type WindowsDetachedRelayLaunch = { + executable: string + args: readonly string[] + cwd: string + stdoutPath: string + stderrPath: string +} +const requireFromRelay = createRequire(__filename) +const INVALID_HANDLE = 0xffff_ffff_ffff_ffffn + +function wide(value: string): Uint16Array { + if (value.includes('\0')) { + throw new Error('Windows launch argument contains NUL') + } + const encoded = new Uint16Array(value.length + 1) + for (let index = 0; index < value.length; index += 1) { + encoded[index] = value.charCodeAt(index) + } + return encoded +} + +export function launchDetachedWindowsRelay(options: WindowsDetachedRelayLaunch): number { + if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) { + throw new Error('Detached relay launch requires 64-bit Windows') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned Bun runtime provides this FFI interface; load failures propagate. + const ffi = requireFromRelay('bun:ffi') as BunFfi + const library = ffi.dlopen('kernel32.dll', { + CreateFileW: { args: ['ptr', 'u32', 'u32', 'ptr', 'u32', 'u32', 'ptr'], returns: 'u64' }, + InitializeProcThreadAttributeList: { args: ['ptr', 'u32', 'u32', 'ptr'], returns: 'i32' }, + UpdateProcThreadAttribute: { + args: ['ptr', 'u32', 'u64', 'ptr', 'u64', 'ptr', 'ptr'], + returns: 'i32' + }, + DeleteProcThreadAttributeList: { args: ['ptr'], returns: 'void' }, + CreateProcessW: { + args: ['ptr', 'ptr', 'ptr', 'ptr', 'i32', 'u32', 'ptr', 'ptr', 'ptr', 'ptr'], + returns: 'i32' + }, + GetLastError: { args: [], returns: 'u32' }, + CloseHandle: { args: ['u64'], returns: 'i32' } + }) + const api = library.symbols + const readLastErrorCode = api.GetLastError + function nativeFailureMessage(message: string): string { + // Last-error can change across FFI; keep it diagnostic, never a retry decision. + const hint = readLastErrorCode() + return hint ? `${message} (Win32 last-error hint ${hint})` : message + } + const handles: Pointer[] = [] + let attributes: Uint8Array | undefined + let attributeHandles: BigUint64Array | undefined + try { + const application = wide(options.executable) + const command = wide([options.executable, ...options.args].map(quoteWindowsArgument).join(' ')) + if (command.length > 32767) { + throw new Error('Windows relay command exceeds the platform limit') + } + const cwd = wide(options.cwd) + const security = new Uint8Array(24) + const securityView = new DataView(security.buffer) + securityView.setUint32(0, security.byteLength, true) + securityView.setUint32(16, 1, true) + function open(path: string, access: number, creation: number): Pointer { + const name = wide(path) + const handle = api.CreateFileW( + ffi.ptr(name), + access, + 7, + ffi.ptr(security), + creation, + 0x80, + null + ) + if (BigInt.asUintN(64, BigInt(handle)) === INVALID_HANDLE || handle === 0 || handle === 0n) { + throw new Error(nativeFailureMessage('Unable to open detached relay standard stream')) + } + handles.push(handle) + return handle + } + const input = open('NUL', 0x80000000, 3) + // Append and share-delete preserve pre-JS errors without preventing daemon log rotation. + const output = open(options.stdoutPath, 4, 4) + const error = open(options.stderrPath, 4, 4) + const size = new BigUint64Array(1) + api.InitializeProcThreadAttributeList(null, 1, 0, ffi.ptr(size)) + if (size[0] === 0n || size[0] > 65536n) { + throw new Error('Invalid Windows attribute-list size') + } + const storage = new Uint8Array(Number(size[0])) + if (!api.InitializeProcThreadAttributeList(ffi.ptr(storage), 1, 0, ffi.ptr(size))) { + throw new Error(nativeFailureMessage('Unable to initialize Windows handle allowlist')) + } + attributes = storage + const allowed = new BigUint64Array([BigInt(input), BigInt(output), BigInt(error)]) + attributeHandles = allowed + if ( + !api.UpdateProcThreadAttribute( + ffi.ptr(attributes), + 0, + 0x00020002, + ffi.ptr(allowed), + allowed.byteLength, + null, + null + ) + ) { + throw new Error(nativeFailureMessage('Unable to restrict inherited Windows handles')) + } + const startup = new Uint8Array(112) + const startupView = new DataView(startup.buffer) + startupView.setUint32(0, startup.byteLength, true) + startupView.setUint32(60, 0x100, true) + startupView.setBigUint64(80, BigInt(input), true) + startupView.setBigUint64(88, BigInt(output), true) + startupView.setBigUint64(96, BigInt(error), true) + startupView.setBigUint64(104, BigInt(ffi.ptr(attributes)), true) + const result = new Uint8Array(24) + // Break away from sshd's job; inherit only our three standard-stream handles. + const flags = 0x01000000 | 0x08000000 | 0x00080000 + if ( + !api.CreateProcessW( + ffi.ptr(application), + ffi.ptr(command), + null, + null, + 1, + flags, + null, + ffi.ptr(cwd), + ffi.ptr(startup), + ffi.ptr(result) + ) + ) { + throw new Error( + nativeFailureMessage('Unable to start detached relay with Windows job breakaway') + ) + } + const processInfo = new DataView(result.buffer) + handles.push(processInfo.getBigUint64(0, true), processInfo.getBigUint64(8, true)) + return processInfo.getUint32(16, true) + } finally { + if (attributes) { + api.DeleteProcThreadAttributeList(ffi.ptr(attributes)) + // The attribute API retains this buffer until the list is destroyed. + attributeHandles?.fill(0n) + } + for (const handle of handles.toReversed()) { + api.CloseHandle(handle) + } + library.close() + } +}