From 5b59765db3f5bd3a95b057ce56706d0755420300 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 16:40:15 -0700 Subject: [PATCH 1/7] fix(ssh): launch Windows relay with Bun breakaway FFI --- src/main/ssh/ssh-relay-deploy.ts | 14 +---- src/relay/relay-launch-options.ts | 5 ++ src/relay/relay.ts | 15 ++++- src/relay/windows-detached-launch.ts | 93 ++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 src/relay/windows-detached-launch.ts diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 6316d0bf7f6f..7b8c9280d91c 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -1481,7 +1481,7 @@ 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. + // Why: the relay asks Bun's CreateProcessW FFI to break away from sshd's job. const quoted = (value: string): string => `"${value.replace(/"/g, '\\"')}"` const relayCommandLine = [ quoted(nodePath), @@ -1505,16 +1505,8 @@ function windowsRelayLaunchCommand( `1>${quoted(logFile)}`, `2>${quoted(errFile)}` ].join(' ') - const wmiCommandLine = `cmd.exe /d /s /c "${relayCommandLine}"` - return commandWithNodePath( - 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('; ') - ) + const detachedLaunch = `& ${powerShellLiteral(nodePath)} --no-env-file --config=NUL --no-install ${powerShellLiteral(relayScript)} --spawn-detached --spawn-command ${powerShellLiteral(relayCommandLine)}; if ($LASTEXITCODE -ne 0) { throw "Bun detached launch failed with exit $LASTEXITCODE" }` + return commandWithNodePath(hostPlatform, nodePath, remoteDir, [detachedLaunch].join('; ')) } async function probeWindowsRelayPipe( diff --git a/src/relay/relay-launch-options.ts b/src/relay/relay-launch-options.ts index f9e057ce55f7..360d6244a1f0 100644 --- a/src/relay/relay-launch-options.ts +++ b/src/relay/relay-launch-options.ts @@ -18,6 +18,7 @@ export type RelayLaunchOptions = { connectMode: boolean detached: boolean cliMode: boolean + spawnDetachedMode: boolean sockPath: string endpointDir?: string logFile?: string @@ -31,6 +32,7 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { let connectMode = false let detached = false let cliMode = false + let spawnDetachedMode = false let sockPath = '' let endpointDir: string | undefined let logFile: string | undefined @@ -48,6 +50,8 @@ 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] === '--detached') { detached = true } else if (argv[i] === '--sock-path' && argv[i + 1]) { @@ -75,6 +79,7 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { connectMode, detached, cliMode, + spawnDetachedMode, sockPath, endpointDir, logFile, diff --git a/src/relay/relay.ts b/src/relay/relay.ts index c245eb32e7d3..6f80142f25b3 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -1,4 +1,3 @@ - // Orca Relay — remote-host daemon and reconnect bridge entry point. import { parseRelayLaunchOptions, readRelayEndpointCredential } from './relay-launch-options' @@ -7,6 +6,7 @@ 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 +23,19 @@ async function main(): Promise { ) return } + if (options.spawnDetachedMode) { + const marker = process.argv.indexOf('--spawn-detached') + const args = marker === -1 ? [] : process.argv.slice(marker + 1) + const commandMarker = args.indexOf('--spawn-command') + if (commandMarker === -1 || !args[commandMarker + 1]) { + throw new Error('Detached launch command is unavailable') + } + const command = args[commandMarker + 1] + const comspec = process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe' + const pid = launchDetachedWindowsRelay(comspec, ['/d', '/s', '/c', command], process.cwd()) + 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.ts b/src/relay/windows-detached-launch.ts new file mode 100644 index 000000000000..cde80fd0ae26 --- /dev/null +++ b/src/relay/windows-detached-launch.ts @@ -0,0 +1,93 @@ +import { createRequire } from 'node:module' + +type BunFfi = { + dlopen( + name: string, + symbols: Record + ): { + symbols: T + } + ptr(view: ArrayBufferView): number | bigint +} + +type Kernel32 = { + CreateProcessW( + application: number | bigint, + commandLine: number | bigint, + processAttributes: null, + threadAttributes: null, + inheritHandles: number, + flags: number, + environment: null, + currentDirectory: number | bigint, + startupInfo: number | bigint, + processInfo: number | bigint + ): number + CloseHandle(handle: number | bigint): number +} + +const requireFromRelay = createRequire(__filename) +const CREATE_BREAKAWAY_FROM_JOB = 0x01000000 +const CREATE_NO_WINDOW = 0x08000000 + +function wide(value: string): Uint16Array { + const encoded = new Uint16Array(value.length + 1) + for (let index = 0; index < value.length; index += 1) { + encoded[index] = value.charCodeAt(index) + } + return encoded +} + +function quoteWindowsArgument(value: string): string { + if (/^[^\s"]+$/.test(value)) { + return value + } + return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"` +} + +export function launchDetachedWindowsRelay( + executable: string, + args: readonly string[], + cwd: string +): number { + if (process.platform !== 'win32') { + throw new Error('Windows detached launch is unavailable') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Bun exposes the declared bun:ffi ABI. + const ffi = requireFromRelay('bun:ffi') as BunFfi + const kernel = ffi.dlopen('kernel32.dll', { + CreateProcessW: { + args: ['ptr', 'ptr', 'ptr', 'ptr', 'i32', 'u32', 'ptr', 'ptr', 'ptr', 'ptr'], + returns: 'i32' + }, + CloseHandle: { args: ['ptr'], returns: 'i32' } + }) + const application = wide(executable) + const commandLine = wide([executable, ...args].map(quoteWindowsArgument).join(' ')) + const directory = wide(cwd) + const startup = new Uint8Array(104) + new DataView(startup.buffer).setUint32(0, startup.byteLength, true) + const processInfo = new Uint8Array(24) + const symbols = kernel.symbols + const created = symbols.CreateProcessW( + ffi.ptr(application), + ffi.ptr(commandLine), + null, + null, + 0, + CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW, + null, + ffi.ptr(directory), + ffi.ptr(startup), + ffi.ptr(processInfo) + ) + if (!created) { + throw new Error('CreateProcessW failed') + } + const view = new DataView(processInfo.buffer) + const process = view.getBigUint64(0, true) + const thread = view.getBigUint64(8, true) + symbols.CloseHandle(thread) + symbols.CloseHandle(process) + return view.getUint32(16, true) +} From 127a41cdd22084197b96522d6ceb97d2a6883723 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 16:41:23 -0700 Subject: [PATCH 2/7] fix(ssh): own detached relay log handles --- src/relay/windows-detached-launch.ts | 73 ++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/src/relay/windows-detached-launch.ts b/src/relay/windows-detached-launch.ts index cde80fd0ae26..70c2ababf3d3 100644 --- a/src/relay/windows-detached-launch.ts +++ b/src/relay/windows-detached-launch.ts @@ -11,6 +11,15 @@ type BunFfi = { } type Kernel32 = { + CreateFileW( + path: number | bigint, + access: number, + share: number, + security: null, + creation: number, + flags: number, + template: null + ): number | bigint CreateProcessW( application: number | bigint, commandLine: number | bigint, @@ -29,6 +38,11 @@ type Kernel32 = { const requireFromRelay = createRequire(__filename) const CREATE_BREAKAWAY_FROM_JOB = 0x01000000 const CREATE_NO_WINDOW = 0x08000000 +const STARTF_USESTDHANDLES = 0x100 +const GENERIC_WRITE = 0x40000000 +const FILE_SHARE_READ = 1 +const CREATE_ALWAYS = 2 +const FILE_ATTRIBUTE_NORMAL = 0x80 function wide(value: string): Uint16Array { const encoded = new Uint16Array(value.length + 1) @@ -48,7 +62,9 @@ function quoteWindowsArgument(value: string): string { export function launchDetachedWindowsRelay( executable: string, args: readonly string[], - cwd: string + cwd: string, + stdoutPath?: string, + stderrPath?: string ): number { if (process.platform !== 'win32') { throw new Error('Windows detached launch is unavailable') @@ -60,21 +76,60 @@ export function launchDetachedWindowsRelay( args: ['ptr', 'ptr', 'ptr', 'ptr', 'i32', 'u32', 'ptr', 'ptr', 'ptr', 'ptr'], returns: 'i32' }, + CreateFileW: { args: ['ptr', 'u32', 'u32', 'ptr', 'u32', 'u32', 'ptr'], returns: 'ptr' }, CloseHandle: { args: ['ptr'], returns: 'i32' } }) const application = wide(executable) const commandLine = wide([executable, ...args].map(quoteWindowsArgument).join(' ')) const directory = wide(cwd) const startup = new Uint8Array(104) - new DataView(startup.buffer).setUint32(0, startup.byteLength, true) + const startupView = new DataView(startup.buffer) + startupView.setUint32(0, startup.byteLength, true) const processInfo = new Uint8Array(24) const symbols = kernel.symbols + const output = stdoutPath + ? symbols.CreateFileW( + ffi.ptr(wide(stdoutPath)), + GENERIC_WRITE, + FILE_SHARE_READ, + null, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + null + ) + : null + const error = stderrPath + ? symbols.CreateFileW( + ffi.ptr(wide(stderrPath)), + GENERIC_WRITE, + FILE_SHARE_READ, + null, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + null + ) + : null + if (stdoutPath && BigInt(output as number | bigint) === BigInt('18446744073709551615')) { + throw new Error('stdout log open failed') + } + if (stderrPath && BigInt(error as number | bigint) === BigInt('18446744073709551615')) { + throw new Error('stderr log open failed') + } + if (output !== null || error !== null) { + startupView.setUint32(60, STARTF_USESTDHANDLES, true) + if (output !== null) { + startupView.setBigUint64(88, BigInt(output as number | bigint), true) + } + if (error !== null) { + startupView.setBigUint64(96, BigInt(error as number | bigint), true) + } + } const created = symbols.CreateProcessW( ffi.ptr(application), ffi.ptr(commandLine), null, null, - 0, + output !== null || error !== null ? 1 : 0, CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW, null, ffi.ptr(directory), @@ -82,11 +137,23 @@ export function launchDetachedWindowsRelay( ffi.ptr(processInfo) ) if (!created) { + if (output !== null) { + symbols.CloseHandle(output) + } + if (error !== null) { + symbols.CloseHandle(error) + } throw new Error('CreateProcessW failed') } const view = new DataView(processInfo.buffer) const process = view.getBigUint64(0, true) const thread = view.getBigUint64(8, true) + if (output !== null) { + symbols.CloseHandle(output) + } + if (error !== null) { + symbols.CloseHandle(error) + } symbols.CloseHandle(thread) symbols.CloseHandle(process) return view.getUint32(16, true) From 1a058a2cca322408c78fe12830b605d875ea6999 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 16:47:03 -0700 Subject: [PATCH 3/7] fix(ssh): isolate detached relay argv and inherited handles --- src/main/ssh/ssh-relay-deploy.ts | 34 +-- src/relay/relay-launch-options.test.ts | 47 +++- src/relay/relay-launch-options.ts | 25 ++- src/relay/relay.ts | 23 +- src/relay/windows-detached-launch.test.ts | 153 +++++++++++++ src/relay/windows-detached-launch.ts | 255 +++++++++++++--------- 6 files changed, 401 insertions(+), 136 deletions(-) create mode 100644 src/relay/windows-detached-launch.test.ts diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 7b8c9280d91c..eafa4596bc99 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -1481,32 +1481,32 @@ function windowsRelayLaunchCommand( ripgrepPath?: string ): string { const relayScript = joinRemotePath(hostPlatform, remoteDir, 'relay.js') - // Why: the relay asks Bun's CreateProcessW FFI to break away from sshd's job. - 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 detachedLaunch = `& ${powerShellLiteral(nodePath)} --no-env-file --config=NUL --no-install ${powerShellLiteral(relayScript)} --spawn-detached --spawn-command ${powerShellLiteral(relayCommandLine)}; if ($LASTEXITCODE -ne 0) { throw "Bun detached launch failed with exit $LASTEXITCODE" }` - return commandWithNodePath(hostPlatform, nodePath, remoteDir, [detachedLaunch].join('; ')) + logFile, + '--launch-error-file', + errFile, + ...(ripgrepPath ? ['--ripgrep-path', ripgrepPath] : []) + ] + return commandWithNodePath( + hostPlatform, + nodePath, + remoteDir, + `& ${powerShellLiteral(nodePath)} ${args.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { throw "Bun detached launch failed with exit $LASTEXITCODE" }` + ) } async function probeWindowsRelayPipe( diff --git a/src/relay/relay-launch-options.test.ts b/src/relay/relay-launch-options.test.ts index e16d3cad783d..524ce9140c3f 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,44 @@ 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('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 360d6244a1f0..c4c48b4b6bd3 100644 --- a/src/relay/relay-launch-options.ts +++ b/src/relay/relay-launch-options.ts @@ -18,7 +18,8 @@ export type RelayLaunchOptions = { connectMode: boolean detached: boolean cliMode: boolean - spawnDetachedMode: boolean + spawnDetachedMode?: boolean + launchErrorFile?: string sockPath: string endpointDir?: string logFile?: string @@ -33,6 +34,7 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { let detached = false let cliMode = false let spawnDetachedMode = false + let launchErrorFile: string | undefined let sockPath = '' let endpointDir: string | undefined let logFile: string | undefined @@ -52,6 +54,8 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { 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]) { @@ -80,6 +84,7 @@ export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { detached, cliMode, spawnDetachedMode, + launchErrorFile, sockPath, endpointDir, logFile, @@ -88,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 6f80142f25b3..8919a87d8b4f 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -1,6 +1,10 @@ // 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' @@ -24,15 +28,16 @@ async function main(): Promise { return } if (options.spawnDetachedMode) { - const marker = process.argv.indexOf('--spawn-detached') - const args = marker === -1 ? [] : process.argv.slice(marker + 1) - const commandMarker = args.indexOf('--spawn-command') - if (commandMarker === -1 || !args[commandMarker + 1]) { - throw new Error('Detached launch command is unavailable') + if (!options.logFile || !options.launchErrorFile) { + throw new Error('Detached relay launch requires stdout and stderr paths') } - const command = args[commandMarker + 1] - const comspec = process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe' - const pid = launchDetachedWindowsRelay(comspec, ['/d', '/s', '/c', command], process.cwd()) + 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 } diff --git a/src/relay/windows-detached-launch.test.ts b/src/relay/windows-detached-launch.test.ts new file mode 100644 index 000000000000..486dff99d963 --- /dev/null +++ b/src/relay/windows-detached-launch.test.ts @@ -0,0 +1,153 @@ +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 + } + ), + 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() + 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 index 70c2ababf3d3..da0fb1985546 100644 --- a/src/relay/windows-detached-launch.ts +++ b/src/relay/windows-detached-launch.ts @@ -1,50 +1,72 @@ 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): number | bigint + ptr(view: ArrayBufferView): Pointer } - type Kernel32 = { CreateFileW( - path: number | bigint, + path: Pointer, access: number, share: number, - security: null, + security: Pointer, creation: number, flags: number, template: null - ): number | bigint + ): 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: number | bigint, - commandLine: number | bigint, - processAttributes: null, - threadAttributes: null, + application: Pointer, + command: Pointer, + processSecurity: null, + threadSecurity: null, inheritHandles: number, flags: number, environment: null, - currentDirectory: number | bigint, - startupInfo: number | bigint, - processInfo: number | bigint + cwd: Pointer, + startup: Pointer, + result: Pointer ): number - CloseHandle(handle: number | bigint): number + CloseHandle(handle: Pointer): number } +export type WindowsDetachedRelayLaunch = { + executable: string + args: readonly string[] + cwd: string + stdoutPath: string + stderrPath: string +} const requireFromRelay = createRequire(__filename) -const CREATE_BREAKAWAY_FROM_JOB = 0x01000000 -const CREATE_NO_WINDOW = 0x08000000 -const STARTF_USESTDHANDLES = 0x100 -const GENERIC_WRITE = 0x40000000 -const FILE_SHARE_READ = 1 -const CREATE_ALWAYS = 2 -const FILE_ATTRIBUTE_NORMAL = 0x80 +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) @@ -52,109 +74,126 @@ function wide(value: string): Uint16Array { return encoded } -function quoteWindowsArgument(value: string): string { - if (/^[^\s"]+$/.test(value)) { - return value +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') } - return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"` -} - -export function launchDetachedWindowsRelay( - executable: string, - args: readonly string[], - cwd: string, - stdoutPath?: string, - stderrPath?: string -): number { - if (process.platform !== 'win32') { - throw new Error('Windows detached launch is unavailable') - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Bun exposes the declared bun:ffi ABI. + // 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 kernel = ffi.dlopen('kernel32.dll', { + 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' }, - CreateFileW: { args: ['ptr', 'u32', 'u32', 'ptr', 'u32', 'u32', 'ptr'], returns: 'ptr' }, - CloseHandle: { args: ['ptr'], returns: 'i32' } + CloseHandle: { args: ['u64'], returns: 'i32' } }) - const application = wide(executable) - const commandLine = wide([executable, ...args].map(quoteWindowsArgument).join(' ')) - const directory = wide(cwd) - const startup = new Uint8Array(104) - const startupView = new DataView(startup.buffer) - startupView.setUint32(0, startup.byteLength, true) - const processInfo = new Uint8Array(24) - const symbols = kernel.symbols - const output = stdoutPath - ? symbols.CreateFileW( - ffi.ptr(wide(stdoutPath)), - GENERIC_WRITE, - FILE_SHARE_READ, - null, - CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, + const api = library.symbols + 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 ) - : null - const error = stderrPath - ? symbols.CreateFileW( - ffi.ptr(wide(stderrPath)), - GENERIC_WRITE, - FILE_SHARE_READ, + if (BigInt.asUintN(64, BigInt(handle)) === INVALID_HANDLE || handle === 0 || handle === 0n) { + throw new Error('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('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, - CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, null ) - : null - if (stdoutPath && BigInt(output as number | bigint) === BigInt('18446744073709551615')) { - throw new Error('stdout log open failed') - } - if (stderrPath && BigInt(error as number | bigint) === BigInt('18446744073709551615')) { - throw new Error('stderr log open failed') - } - if (output !== null || error !== null) { - startupView.setUint32(60, STARTF_USESTDHANDLES, true) - if (output !== null) { - startupView.setBigUint64(88, BigInt(output as number | bigint), true) + ) { + throw new Error('Unable to restrict inherited Windows handles') } - if (error !== null) { - startupView.setBigUint64(96, BigInt(error as number | bigint), true) + 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('Unable to start detached relay with Windows job breakaway') } - } - const created = symbols.CreateProcessW( - ffi.ptr(application), - ffi.ptr(commandLine), - null, - null, - output !== null || error !== null ? 1 : 0, - CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW, - null, - ffi.ptr(directory), - ffi.ptr(startup), - ffi.ptr(processInfo) - ) - if (!created) { - if (output !== null) { - symbols.CloseHandle(output) + 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) } - if (error !== null) { - symbols.CloseHandle(error) + for (const handle of handles.toReversed()) { + api.CloseHandle(handle) } - throw new Error('CreateProcessW failed') - } - const view = new DataView(processInfo.buffer) - const process = view.getBigUint64(0, true) - const thread = view.getBigUint64(8, true) - if (output !== null) { - symbols.CloseHandle(output) - } - if (error !== null) { - symbols.CloseHandle(error) + library.close() } - symbols.CloseHandle(thread) - symbols.CloseHandle(process) - return view.getUint32(16, true) } From 972f200697e66816e380755efcc2ef836347c582 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 17:01:01 -0700 Subject: [PATCH 4/7] fix(ssh): preserve terminal PATH during relay launch --- .../ssh/ssh-relay-deploy-windows-pipe.test.ts | 30 +++++++++++-------- src/main/ssh/ssh-relay-deploy.ts | 9 ++++-- 2 files changed, 23 insertions(+), 16 deletions(-) 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 eafa4596bc99..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, @@ -1501,9 +1505,8 @@ function windowsRelayLaunchCommand( errFile, ...(ripgrepPath ? ['--ripgrep-path', ripgrepPath] : []) ] - return commandWithNodePath( + return commandInRemoteDirectory( hostPlatform, - nodePath, remoteDir, `& ${powerShellLiteral(nodePath)} ${args.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { throw "Bun detached launch failed with exit $LASTEXITCODE" }` ) From 6daf95ecd28c5e49782a67730a241355f5d86689 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 17:04:39 -0700 Subject: [PATCH 5/7] fix(ssh): retain best-effort native launch error codes --- src/relay/windows-detached-launch.test.ts | 3 ++- src/relay/windows-detached-launch.ts | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/relay/windows-detached-launch.test.ts b/src/relay/windows-detached-launch.test.ts index 486dff99d963..149b688e4d0d 100644 --- a/src/relay/windows-detached-launch.test.ts +++ b/src/relay/windows-detached-launch.test.ts @@ -102,6 +102,7 @@ function fixture(failAt = '') { return 1 } ), + GetLastError: vi.fn(() => 5), CloseHandle: vi.fn() } const close = vi.fn() @@ -128,7 +129,7 @@ describe('Windows relay job breakaway', () => { 'closes only acquired handles after %s fails', (failAt) => { const { api, close } = fixture(failAt) - expect(() => launchDetachedWindowsRelay(options)).toThrow() + 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() diff --git a/src/relay/windows-detached-launch.ts b/src/relay/windows-detached-launch.ts index da0fb1985546..704be696f000 100644 --- a/src/relay/windows-detached-launch.ts +++ b/src/relay/windows-detached-launch.ts @@ -50,6 +50,7 @@ type Kernel32 = { startup: Pointer, result: Pointer ): number + GetLastError(): number CloseHandle(handle: Pointer): number } @@ -92,9 +93,16 @@ export function launchDetachedWindowsRelay(options: WindowsDetachedRelayLaunch): 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 @@ -121,7 +129,7 @@ export function launchDetachedWindowsRelay(options: WindowsDetachedRelayLaunch): null ) if (BigInt.asUintN(64, BigInt(handle)) === INVALID_HANDLE || handle === 0 || handle === 0n) { - throw new Error('Unable to open detached relay standard stream') + throw new Error(nativeFailureMessage('Unable to open detached relay standard stream')) } handles.push(handle) return handle @@ -137,7 +145,7 @@ export function launchDetachedWindowsRelay(options: WindowsDetachedRelayLaunch): } const storage = new Uint8Array(Number(size[0])) if (!api.InitializeProcThreadAttributeList(ffi.ptr(storage), 1, 0, ffi.ptr(size))) { - throw new Error('Unable to initialize Windows handle allowlist') + throw new Error(nativeFailureMessage('Unable to initialize Windows handle allowlist')) } attributes = storage const allowed = new BigUint64Array([BigInt(input), BigInt(output), BigInt(error)]) @@ -153,7 +161,7 @@ export function launchDetachedWindowsRelay(options: WindowsDetachedRelayLaunch): null ) ) { - throw new Error('Unable to restrict inherited Windows handles') + throw new Error(nativeFailureMessage('Unable to restrict inherited Windows handles')) } const startup = new Uint8Array(112) const startupView = new DataView(startup.buffer) @@ -180,7 +188,9 @@ export function launchDetachedWindowsRelay(options: WindowsDetachedRelayLaunch): ffi.ptr(result) ) ) { - throw new Error('Unable to start detached relay with Windows job breakaway') + 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)) From e9531e5ad181ef2c1be9d0b93a029f88ac8e0b36 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 17:13:14 -0700 Subject: [PATCH 6/7] test(ssh): verify detached Windows relay handle and job boundaries --- config/scripts/bun-profile-test-paths.mjs | 1 + src/relay/relay-launch-options.test.ts | 29 +++ src/relay/windows-detached-launch-fixture.ts | 222 ++++++++++++++++++ ...indows-detached-launch.integration.test.ts | 134 +++++++++++ 4 files changed, 386 insertions(+) create mode 100644 src/relay/windows-detached-launch-fixture.ts create mode 100644 src/relay/windows-detached-launch.integration.test.ts 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/relay/relay-launch-options.test.ts b/src/relay/relay-launch-options.test.ts index 524ce9140c3f..4fcf3935f338 100644 --- a/src/relay/relay-launch-options.test.ts +++ b/src/relay/relay-launch-options.test.ts @@ -91,6 +91,35 @@ describe('relay launch options', () => { 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/windows-detached-launch-fixture.ts b/src/relay/windows-detached-launch-fixture.ts new file mode 100644 index 000000000000..0f37621327ac --- /dev/null +++ b/src/relay/windows-detached-launch-fixture.ts @@ -0,0 +1,222 @@ +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 + GetLastError(): 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' }, + GetLastError: { args: [], returns: 'u32' }, + GetHandleInformation: { args: ['u64', 'ptr'], returns: 'i32' }, + CloseHandle: { args: ['u64'], returns: 'i32' } +}) +const api = library.symbols +const readLastErrorCode = api.GetLastError +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, 'child.json'), 'utf8')) + assert(Number.isInteger(receipt.pid) && typeof receipt.creation === 'string') + const handle = api.OpenProcess(0x100000 | 0x1000, 0, receipt.pid) + if (!handle) { + assert.equal(readLastErrorCode(), 87, 'unable to prove child exited') + return + } + try { + if (identity(handle) !== receipt.creation) { + return + } + assert.equal(api.WaitForSingleObject(handle, 10_000), 0, 'owned child did not exit') + } 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 path = new Uint16Array(32768) + const size = api.GetFinalPathNameByHandleW( + BigInt(markerHandle), + ffi.ptr(path), + path.length, + 0 + ) + const inheritedPath = + size && size < path.length ? Buffer.from(path.buffer, 0, size * 2).toString('utf16le') : '' + const markerInherited = inheritedPath.endsWith('inheritable-marker.txt') + 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') + 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') + }) + 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) + 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..612a0c0318b3 --- /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' || !runtime)( + '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 }) + 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) { + await writeFile(join(directory, 'stop'), '') + // The fixture never kills by PID; it waits on a handle after matching creation time. + if (await contents(join(directory, 'child.json'))) { + const cleanup = await execute(['inspect', directory]) + expect(cleanup.timedOut, cleanup.stderr).toBe(false) + expect(cleanup.code, cleanup.stderr).toBe(0) + cleaned = true + } else { + // A launch failing before its receipt still has the independent 15-second watchdog. + await delay(16_000) + cleaned = true + } + } + if (!launched || cleaned) { + await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + } + } + }, + 60_000 +) From 399647f574437b66c316c4da767e93ba8fb67ba7 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 17:20:19 -0700 Subject: [PATCH 7/7] test(ssh): fail closed on unverifiable native launcher evidence --- src/relay/windows-detached-launch-fixture.ts | 87 ++++++++++++++----- ...indows-detached-launch.integration.test.ts | 18 ++-- 2 files changed, 76 insertions(+), 29 deletions(-) diff --git a/src/relay/windows-detached-launch-fixture.ts b/src/relay/windows-detached-launch-fixture.ts index 0f37621327ac..b8b86b32bf8c 100644 --- a/src/relay/windows-detached-launch-fixture.ts +++ b/src/relay/windows-detached-launch-fixture.ts @@ -32,7 +32,7 @@ type NativeApi = { ): number OpenProcess(access: number, inherit: number, pid: number): Pointer WaitForSingleObject(handle: Pointer, timeout: number): number - GetLastError(): number + TerminateProcess(handle: Pointer, code: number): number GetHandleInformation(handle: Pointer, flags: Pointer): number CloseHandle(handle: Pointer): number } @@ -57,12 +57,52 @@ const library = ffi.dlopen('kernel32.dll', { GetProcessTimes: { args: ['u64', 'ptr', 'ptr', 'ptr', 'ptr'], returns: 'i32' }, OpenProcess: { args: ['u32', 'i32', 'u32'], returns: 'u64' }, WaitForSingleObject: { args: ['u64', 'u32'], returns: 'u32' }, - GetLastError: { args: [], returns: 'u32' }, + TerminateProcess: { args: ['u64', 'u32'], returns: 'i32' }, GetHandleInformation: { args: ['u64', 'ptr'], returns: 'i32' }, CloseHandle: { args: ['u64'], returns: 'i32' } }) const api = library.symbols -const readLastErrorCode = api.GetLastError +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++) { @@ -106,18 +146,22 @@ async function main(): Promise { const watchdog = setTimeout(() => process.exit(2), 15_000) try { if (mode === 'inspect') { - const receipt = JSON.parse(readFileSync(join(directory, 'child.json'), 'utf8')) + 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, 0, receipt.pid) - if (!handle) { - assert.equal(readLastErrorCode(), 87, 'unable to prove child exited') - return - } + 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 } - assert.equal(api.WaitForSingleObject(handle, 10_000), 0, 'owned child did not exit') + 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) } @@ -135,16 +179,7 @@ async function main(): Promise { } finally { api.CloseHandle(job) } - const path = new Uint16Array(32768) - const size = api.GetFinalPathNameByHandleW( - BigInt(markerHandle), - ffi.ptr(path), - path.length, - 0 - ) - const inheritedPath = - size && size < path.length ? Buffer.from(path.buffer, 0, size * 2).toString('utf16le') : '' - const markerInherited = inheritedPath.endsWith('inheritable-marker.txt') + const markerInherited = markerMatches(BigInt(markerHandle)) publish( join(directory, 'child.json'), JSON.stringify({ @@ -199,6 +234,7 @@ async function main(): Promise { 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], @@ -206,6 +242,16 @@ async function main(): Promise { 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) @@ -213,6 +259,7 @@ async function main(): Promise { process.exit(0) } finally { clearTimeout(watchdog) + objectLibrary.close() library.close() } } diff --git a/src/relay/windows-detached-launch.integration.test.ts b/src/relay/windows-detached-launch.integration.test.ts index 612a0c0318b3..c821df843ad4 100644 --- a/src/relay/windows-detached-launch.integration.test.ts +++ b/src/relay/windows-detached-launch.integration.test.ts @@ -28,7 +28,7 @@ async function contents(path: string): Promise { throw error } } -it.skipIf(process.platform !== 'win32' || !runtime)( +it.skipIf(process.platform !== 'win32')( 'restricts handles, preserves argv and logs, and survives its owned SSH-style job', async () => { if (!runtime) { @@ -84,6 +84,8 @@ it.skipIf(process.platform !== 'win32' || !runtime)( 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) @@ -112,17 +114,15 @@ it.skipIf(process.platform !== 'win32' || !runtime)( expect(await contents(join(directory, 'stderr.log'))).toBe('replacement stderr\n') } finally { if (launched) { - await writeFile(join(directory, 'stop'), '') - // The fixture never kills by PID; it waits on a handle after matching creation time. - if (await contents(join(directory, 'child.json'))) { + // 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).toBe(false) - expect(cleanup.code, cleanup.stderr).toBe(0) + expect(cleanup.timedOut, `${cleanup.stderr}; evidence: ${directory}`).toBe(false) + expect(cleanup.code, `${cleanup.stderr}; evidence: ${directory}`).toBe(0) cleaned = true } else { - // A launch failing before its receipt still has the independent 15-second watchdog. - await delay(16_000) - cleaned = true + await writeFile(join(directory, 'stop'), '') + expect.fail(`Cleanup unverifiable without owned child identity; evidence: ${directory}`) } } if (!launched || cleaned) {