From 14d4bb2e2a4644ecbe24480e0c38bec8afa6f612 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 1 Oct 2026 03:25:42 -0700 Subject: [PATCH] fix(ssh): Windows hosts without Add-Type staging; runtime-store GC on Windows (#24149) * fix(ssh): collect the pinned-Node runtime store on Windows hosts Windows SSH hosts now run runtime-store GC instead of skipping it: one PowerShell inventory reads .runtime-ref-node- and .runtime-node refs from every version dir, and one Get-CimInstance Win32_Process query filtered on an image path under runtimes\ adds process holds (never by image name; a failed query keeps everything). Stale upload stages are swept with the same rule as POSIX. Promotion and the post-upload hold check now take the store lock on Windows too, and the lock's own commands run unwrapped there. Windows relay version-dir liveness now honours .relay-pid (design D5): a live PID answers ALIVE before any pipe is touched, a dead one (ESRCH) plus refusing pipes is exited, anything else is unverifiable. The runtime probe adopts a pinned node.exe an earlier vault reader left without a .verified marker after running it. * fix(ssh): Windows stage fencing and vault runtime go through the verified node.exe Upload-stage file identity on Windows no longer compiles an Add-Type P/Invoke helper when the relay runs on Orca's verified pinned node.exe: the stage commands run a fixed fs.lstatSync(..., {bigint:true}) script through it. It prints the legacy helper's vol:high:low lowercase hex, and identity files are compared after normalising hex spelling, so old and new clients recover each other's stages. Host-Node relays keep the legacy helper; the choice is documented in windows-edr-posture.md. The Windows OpenCode vault reader now installs the pinned runtime through ensureRemoteOrcadNodeRuntime (official zip, host-side extraction, .verified, store lock) instead of uploading a client-extracted node.exe, and the relay dir gains a .runtime-ref-node- so store GC keeps the runtime the vault uses. * test(ssh): run the Windows stage-identity and store-GC tests on the Windows lane The legacy/node.exe identity compatibility test and the Win32_Process hold path were gated to win32 but no CI lane ran them. Add both files to the Windows package lane and a real running-node.exe hold test. * test(ssh): tear down Windows-lane temp trees through removeTreeSync * test(ssh): grant the store lock to the Windows OpenCode runtime setup test The Windows promote now runs under runtimes/.store-lock, so the mocked host must answer the lock's CreateNew step. --------- Co-authored-by: m4air Co-authored-by: m4air --- .github/workflows/pr.yml | 2 + config/scripts/pr-code-change-scope.mjs | 4 +- docs/reference/windows-edr-posture.md | 39 +++ .../orcad-remote-node-runtime-windows.test.ts | 86 ++++-- .../ssh/orcad-remote-node-runtime-windows.ts | 7 +- src/main/ssh/orcad-remote-node-runtime.ts | 62 ++-- .../ssh/relay-version-dir-liveness.test.ts | 68 +++++ .../ssh/remote-node-runtime-store-gc.test.ts | 13 - src/main/ssh/remote-node-runtime-store-gc.ts | 49 +++- .../remote-node-runtime-store-inventory.ts | 13 +- .../ssh/remote-node-runtime-store-lock.ts | 15 +- .../remote-node-runtime-store-windows.test.ts | 267 ++++++++++++++++++ .../ssh/remote-node-runtime-store-windows.ts | 93 ++++++ src/main/ssh/ssh-relay-deploy.ts | 39 ++- .../ssh-relay-opencode-pinned-node.test.ts | 78 ++--- .../ssh/ssh-relay-opencode-pinned-node.ts | 88 ++---- ...sh-relay-opencode-runtime-commands.test.ts | 169 +++-------- .../ssh-relay-opencode-runtime-commands.ts | 72 +---- .../ssh/ssh-relay-opencode-runtime.test.ts | 83 ++++++ src/main/ssh/ssh-relay-opencode-runtime.ts | 81 ++---- .../ssh/ssh-relay-pinned-node-install.test.ts | 16 +- src/main/ssh/ssh-relay-pinned-node-install.ts | 10 +- .../ssh/ssh-relay-upload-stage-commands.ts | 24 +- ...ssh-relay-upload-stage-windows-commands.ts | 60 +++- ...elay-upload-stage-windows-identity.test.ts | 201 +++++++++++++ src/main/ssh/ssh-remote-commands.ts | 76 +++-- 26 files changed, 1196 insertions(+), 519 deletions(-) create mode 100644 src/main/ssh/remote-node-runtime-store-windows.test.ts create mode 100644 src/main/ssh/remote-node-runtime-store-windows.ts create mode 100644 src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e54431318ab..8844a69a595 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1158,6 +1158,8 @@ jobs: src/main/ipc/pty-codex-account-attribution.test.ts src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts src/relay/windows-port-scan.win32.test.ts + src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts + src/main/ssh/remote-node-runtime-store-windows.test.ts # Why the :parallel variant: identical to build:release except the three # electron-vite targets overlap instead of running back to back. The Linux package diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 88627245173..c72825c9fe1 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -321,7 +321,9 @@ const WINDOWS_PACKAGE_TESTS = [ 'src/main/runtime/unreadable-secret-store-preservation.win32.test.ts', 'src/main/ipc/pty-codex-account-attribution.test.ts', 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts', - 'src/relay/windows-port-scan.win32.test.ts' + 'src/relay/windows-port-scan.win32.test.ts', + 'src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts', + 'src/main/ssh/remote-node-runtime-store-windows.test.ts' ] const DESKTOP_IRRELEVANT_PREFIXES = [ diff --git a/docs/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md index edb3a296337..46fd1f1bc14 100644 --- a/docs/reference/windows-edr-posture.md +++ b/docs/reference/windows-edr-posture.md @@ -335,6 +335,45 @@ flight and unmerged at the time of writing; check the code rather than this paragraph for what the shipped build does. Screen capture and `SendInput` are inherent to the feature and no refactor removes them. +### SSH hosts: upload-stage file identity and runtime-store GC + +These run on the _remote_ Windows host over SSH, not on the desktop, but the +host's EDR scores them the same way. + +The relay upload stage fences each slot with the directory's file ID (volume +serial plus file index). That used to come from `Add-Type -TypeDefinition` over +a P/Invoke of `GetFileInformationByHandle`, compiled in every stage command. When +the relay runs on Orca's pinned Node (design D5), node.exe is already hashed +against the pin and has run once, so the stage commands now ask it instead: +`src/main/ssh/ssh-relay-upload-stage-windows-commands.ts` runs +`node.exe -e -- `, a fixed `fs.lstatSync(..., { bigint: true })` +with the path as an argument. libuv fills `dev` and `ino` from the same volume +serial and file index, so both readers write the same `vol:high:low` lowercase +hex, and identity files are compared after normalising hex spelling. An old +client can recover a stage a new one reserved, and the reverse. + +Two alternatives were rejected: + +- **PowerShell alone.** Neither .NET Framework (Windows PowerShell 5.1) nor .NET + exposes a file index without P/Invoke, which is what `Add-Type` compiles. + `fsutil file queryfileid` would spawn another binary per lookup and prints a + different format, which would break mixed-version recovery. +- **Host Node.** Relays still on the host's own Node (rung C and the legacy + path) keep the `Add-Type` helper, because Orca has not verified that binary. + That is the one remaining `Add-Type` site on SSH hosts; it goes when those + rungs do. + +A lookup costs one short-lived node.exe per existing stage directory the command +inspects, usually one or two. It is not a loop over the whole pool. + +Runtime-store GC (`src/main/ssh/remote-node-runtime-store-windows.ts`) reads the +store in one PowerShell invocation. It learns which runtimes are in use from a +single `Get-CimInstance Win32_Process` query, filtered on an image path under +`runtimes\`. It never matches on the image name, so another program's node.exe +holds nothing. If the query fails, no process check has run and the pass keeps +everything. Windows itself also refuses to delete a running image, which is a +second safeguard. + ## Signing is not the gate The most useful calibration in the whole incident set came from the reporter's diff --git a/src/main/ssh/orcad-remote-node-runtime-windows.test.ts b/src/main/ssh/orcad-remote-node-runtime-windows.test.ts index 58072790d73..41acb325808 100644 --- a/src/main/ssh/orcad-remote-node-runtime-windows.test.ts +++ b/src/main/ssh/orcad-remote-node-runtime-windows.test.ts @@ -76,7 +76,10 @@ describe('Windows runtime store commands', () => { $runtimeDir = 'C:/Users/u/.orca-remote/runtimes/node-ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32' $exe = 'C:/Users/u/.orca-remote/runtimes/node-ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32/node.exe' $verified = 'C:/Users/u/.orca-remote/runtimes/node-ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32/.verified' - if ((Test-Path -LiteralPath $verified -PathType Leaf) -and ((Get-OrcaSha256 $exe) -eq 'ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32')) { Write-Output 'ORCA_NODE_RUNTIME_READY'; exit 0 } + if ((Get-OrcaSha256 $exe) -eq 'ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32') { + if (Test-Path -LiteralPath $verified -PathType Leaf) { Write-Output 'ORCA_NODE_RUNTIME_READY'; exit 0 } + try { $adoptOut = ((& $exe --version 2>&1) | ForEach-Object { "$_" }) -join ''; if (($LASTEXITCODE -eq 0) -and ($adoptOut.Trim() -eq 'v24.21.0')) { [IO.File]::WriteAllText($verified, ''); Write-Output 'ORCA_NODE_RUNTIME_READY'; exit 0 } } catch { } + } Write-Output 'ORCA_NODE_RUNTIME_MISSING'" `) expect( @@ -84,6 +87,19 @@ describe('Windows runtime store commands', () => { ).toContain(`New-Item -ItemType Directory -Force -Path '${stageDir}' -ErrorAction Stop`) }) + it('adopts a pinned node.exe an earlier vault reader left without a marker, after running it', () => { + const script = decodeRemotePowerShellScript( + windowsNodeRuntimeProbeCommand(runtimeDir, target, stageDir) + ) + const hashed = script.indexOf(`if ((Get-OrcaSha256 $exe) -eq '${asset.executableSha256}') {`) + const ran = script.indexOf('& $exe --version') + const marked = script.indexOf("[IO.File]::WriteAllText($verified, '')") + expect(hashed).toBeGreaterThan(0) + expect(ran).toBeGreaterThan(hashed) + expect(marked).toBeGreaterThan(ran) + expect(script).toContain(`($adoptOut.Trim() -eq 'v${NODE_RUNTIME_PIN.version}')`) + }) + it('checks only the marker and node.exe on the warm path', () => { const script = decodeRemotePowerShellScript(remoteNodeRuntimePresentCommand(host, runtimeDir)) expect(script).not.toContain('Get-FileHash') @@ -171,25 +187,61 @@ describe('ensureRemoteOrcadNodeRuntime on Windows', () => { expect(uploadRelayDirectory).not.toHaveBeenCalled() }) - it('uploads into the stage the probe created and promotes with a long budget in two execs', async () => { - vi.mocked(execCommand) - .mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) - .mockResolvedValueOnce(`extracted-by tar\r\n${REMOTE_NODE_RUNTIME_READY}\r\n`) + /** Answers the probe, the store lock and the promote script by what each script does. */ + function answer(promote: string, reprobe = REMOTE_NODE_RUNTIME_MISSING): string[] { + const scripts: string[] = [] + let probes = 0 + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + const script = decodeRemotePowerShellScript(command) + scripts.push(script) + if (script.includes('.store-lock') && script.includes('CreateNew')) { + return 'OK' + } + if (script.includes('Invoke-OrcaPromote')) { + return promote + } + if (script.includes(REMOTE_NODE_RUNTIME_MISSING)) { + return ++probes === 1 ? REMOTE_NODE_RUNTIME_MISSING : reprobe + } + return '' + }) + return scripts + } + + it('uploads into the stage the probe created and promotes under the store lock with a long budget', async () => { + const scripts = answer(`extracted-by tar\r\n${REMOTE_NODE_RUNTIME_READY}\r\n`) await ensureRemoteOrcadNodeRuntime({ conn, host, slotDir: relayDir, target, archivePath }) const calls = vi.mocked(execCommand).mock.calls - // The promote script removes its own stage, so no third powershell.exe runs. - expect(calls).toHaveLength(2) for (const call of calls) { expect(call[1]).toMatch(/^powershell\.exe /) expect(call[2]).toMatchObject({ wrapCommand: false }) } - const probe = decodeRemotePowerShellScript(calls[0][1]) - const stage = /New-Item -ItemType Directory -Force -Path '([^']+)'/.exec(probe)?.[1] + const stage = /New-Item -ItemType Directory -Force -Path '([^']+)'/.exec(scripts[0])?.[1] expect(stage).toMatch( /^C:\/Users\/u\/\.orca-remote\/runtimes\/\.stage-node-[0-9a-f]{64}-[0-9a-f]{16}$/ ) expect(vi.mocked(uploadRelayDirectory).mock.calls[0][2]).toBe(stage) - expect(calls[1][2]).toMatchObject({ timeoutMs: WINDOWS_NODE_RUNTIME_PROMOTE_TIMEOUT_MS }) + const locked = scripts.findIndex((s) => s.includes('CreateNew')) + const promoted = scripts.findIndex((s) => s.includes('Invoke-OrcaPromote')) + const released = scripts.findIndex( + (s) => s.startsWith('Remove-Item') && s.includes('.store-lock') + ) + // Store GC collects on Windows too, so promotion holds the lock it takes (design D5). + expect(locked).toBeGreaterThan(0) + expect(promoted).toBeGreaterThan(locked) + expect(released).toBeGreaterThan(promoted) + expect(calls[promoted][2]).toMatchObject({ timeoutMs: WINDOWS_NODE_RUNTIME_PROMOTE_TIMEOUT_MS }) + // The promote script removes its own stage, so no separate cleanup runs. + expect(scripts.some((s) => s.startsWith('Remove-Item') && s.includes('.stage-node-'))).toBe( + false + ) + }) + + it('removes its stage when a sibling published the pin while this client uploaded', async () => { + const scripts = answer('unused', REMOTE_NODE_RUNTIME_READY) + await ensureRemoteOrcadNodeRuntime({ conn, host, slotDir: relayDir, target, archivePath }) + expect(scripts.some((s) => s.includes('Invoke-OrcaPromote'))).toBe(false) + expect(scripts.at(-1)).toMatch(/^Remove-Item -LiteralPath '[^']*\.stage-node-/) }) it('still removes the stage when the upload fails before promote runs', async () => { @@ -209,11 +261,7 @@ describe('ensureRemoteOrcadNodeRuntime on Windows', () => { }) it('surfaces a post-write change as a security-software verdict', async () => { - vi.mocked(execCommand) - .mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) - .mockResolvedValueOnce( - 'ORCA_NODE_RUNTIME_SECURITY_MODIFIED node.exe changed after it ran\r\n' - ) + answer('ORCA_NODE_RUNTIME_SECURITY_MODIFIED node.exe changed after it ran\r\n') const failure = await ensureRemoteOrcadNodeRuntime({ conn, host, @@ -226,11 +274,9 @@ describe('ensureRemoteOrcadNodeRuntime on Windows', () => { }) it('carries what node.exe said when it would not run', async () => { - vi.mocked(execCommand) - .mockResolvedValueOnce(REMOTE_NODE_RUNTIME_MISSING) - .mockResolvedValueOnce( - "ORCA_NODE_RUNTIME_SELFTEST_FAILED\r\nORCA_RUNTIME_EXIT=-1\r\nProgram 'node.exe' failed to run: This program is blocked by group policy.\r\n" - ) + answer( + "ORCA_NODE_RUNTIME_SELFTEST_FAILED\r\nORCA_RUNTIME_EXIT=-1\r\nProgram 'node.exe' failed to run: This program is blocked by group policy.\r\n" + ) const failure = await ensureRemoteOrcadNodeRuntime({ conn, host, diff --git a/src/main/ssh/orcad-remote-node-runtime-windows.ts b/src/main/ssh/orcad-remote-node-runtime-windows.ts index 2fd34c40cbe..641e1fd2a7d 100644 --- a/src/main/ssh/orcad-remote-node-runtime-windows.ts +++ b/src/main/ssh/orcad-remote-node-runtime-windows.ts @@ -63,7 +63,12 @@ export function windowsNodeRuntimeProbeCommand( [ ...prelude(), ...runtimeVariables(runtimeDir), - `if ((Test-Path -LiteralPath $verified -PathType Leaf) -and ((Get-OrcaSha256 $exe) -eq ${powerShellLiteral(NODE_RUNTIME_ASSETS[target].executableSha256)})) { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)}; exit 0 }`, + `if ((Get-OrcaSha256 $exe) -eq ${powerShellLiteral(NODE_RUNTIME_ASSETS[target].executableSha256)}) {`, + `if (Test-Path -LiteralPath $verified -PathType Leaf) { Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)}; exit 0 }`, + // Why adopt: earlier Windows vault readers left the pinned node.exe here with no marker. + // Running it is the same check promotion makes before it writes one. + `try { $adoptOut = ((& $exe --version 2>&1) | ForEach-Object { "$_" }) -join ''; if (($LASTEXITCODE -eq 0) -and ($adoptOut.Trim() -eq ${powerShellLiteral(`v${NODE_RUNTIME_PIN.version}`)})) { [IO.File]::WriteAllText($verified, ''); Write-Output ${powerShellLiteral(REMOTE_NODE_RUNTIME_READY)}; exit 0 } } catch { }`, + '}', ...(stageDir ? [ `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(stageDir)} -ErrorAction Stop` diff --git a/src/main/ssh/orcad-remote-node-runtime.ts b/src/main/ssh/orcad-remote-node-runtime.ts index 0303b90fcf4..e9a7d8c53f0 100644 --- a/src/main/ssh/orcad-remote-node-runtime.ts +++ b/src/main/ssh/orcad-remote-node-runtime.ts @@ -274,40 +274,38 @@ export async function ensureRemoteOrcadNodeRuntime(options: { await exec(`mkdir -p ${shellEscape(stageDir)}`, { signal }) } await remoteStep(() => uploadRelayDirectory(conn, uploadDir, stageDir, host, { signal })) - const promoted = windows - ? await exec(windowsNodeRuntimePromoteCommand({ stageDir, archive, runtimeDir, target }), { - signal, - timeoutMs: WINDOWS_NODE_RUNTIME_PROMOTE_TIMEOUT_MS - }) - : // Why re-probe under the lock: a sibling installer may have published this pin while we uploaded. - await remoteStep(() => - withRuntimeStoreLock( - conn, - host, - remoteDirname(runtimeDir, host), - async () => - ( - await execCommand(conn, probeRemoteNodeRuntimeCommand(host, runtimeDir, target), { - signal - }) - ).trim() === REMOTE_NODE_RUNTIME_READY - ? REMOTE_NODE_RUNTIME_READY - : execCommand( - conn, - promoteRemoteNodeRuntimeCommand(host, { - stageDir, - archive, - runtimeDir, - target, - token - }), - { signal } - ), - signal + let promoteRan = false + // Why unwrapped on Windows: these are already self-contained powershell.exe command lines. + const runLocked = (command: string, timeoutMs?: number): Promise => + execCommand(conn, command, { signal, timeoutMs, wrapCommand: !windows }) + const promote = (): Promise => { + promoteRan = true + return windows + ? runLocked( + windowsNodeRuntimePromoteCommand({ stageDir, archive, runtimeDir, target }), + WINDOWS_NODE_RUNTIME_PROMOTE_TIMEOUT_MS ) - ) + : runLocked( + promoteRemoteNodeRuntimeCommand(host, { stageDir, archive, runtimeDir, target, token }) + ) + } + // Why the lock on Windows too: store GC collects there as well (design D5). + const promoted = await remoteStep(() => + withRuntimeStoreLock( + conn, + host, + remoteDirname(runtimeDir, host), + // Why re-probe under the lock: a sibling installer may have published this pin while we uploaded. + async () => + (await runLocked(probeRemoteNodeRuntimeCommand(host, runtimeDir, target))).trim() === + REMOTE_NODE_RUNTIME_READY + ? REMOTE_NODE_RUNTIME_READY + : promote(), + signal + ) + ) // Why: the Windows promote script removes its stage on every path; skip a second powershell.exe. - hostRemovedStage = windows + hostRemovedStage = windows && promoteRan assertRemoteNodeRuntimePromoted(promoted) return { executable, transfer: 'uploaded' } } catch (error) { diff --git a/src/main/ssh/relay-version-dir-liveness.test.ts b/src/main/ssh/relay-version-dir-liveness.test.ts index 0d05ae6bc95..7abf4017cb1 100644 --- a/src/main/ssh/relay-version-dir-liveness.test.ts +++ b/src/main/ssh/relay-version-dir-liveness.test.ts @@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process' import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer, type Server } from 'node:net' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { RELAY_PID_FILENAME } from '../../shared/relay-artifacts' @@ -10,6 +11,7 @@ import { relayVersionDirLivenessCommand } from './relay-version-dir-liveness' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { WINDOWS_RELAY_LIVENESS_JS } from './ssh-remote-commands' const host = getRemoteHostPlatform('linux-x64') const posixOnly = process.platform === 'win32' ? describe.skip : describe @@ -129,3 +131,69 @@ describe('parseRelayVersionDirLiveness', () => { expect(parseRelayVersionDirLiveness('UNKNOWN')).toBe('unverifiable') }) }) + +/** The Windows probe's JavaScript under the local Node; only the pipe names are Windows-only. */ +describe('Windows relay liveness script (design D5 .relay-pid)', () => { + const dirs: string[] = [] + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + function windowsProbe(setup: (dir: string) => void): string { + const dir = mkdtempSync(join(tmpdir(), 'rvl-win-')) + dirs.push(dir) + setup(dir) + return parseRelayVersionDirLiveness( + execFileSync(process.execPath, ['-e', WINDOWS_RELAY_LIVENESS_JS, dir], { encoding: 'utf8' }) + ) + } + + const deadPid = (): number => + Number.parseInt( + spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { + encoding: 'utf8' + }).stdout, + 10 + ) + const marker = (dir: string): void => + writeFileSync( + join(dir, '.windows-active-pipe-a'), + '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' + ) + + it('is live for a running recorded PID without touching any pipe', () => { + expect( + windowsProbe((dir) => { + writeFileSync(join(dir, RELAY_PID_FILENAME), `${process.pid}\n`) + marker(dir) + }) + ).toBe('live') + }) + + it('is exited for a dead recorded PID whose pipes all refuse', () => { + expect( + windowsProbe((dir) => { + writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid()}\n`) + marker(dir) + }) + ).toBe('exited') + }) + + it('is exited for a dead recorded PID that left no pipe marker', () => { + expect( + windowsProbe((dir) => writeFileSync(join(dir, RELAY_PID_FILENAME), `${deadPid()}\n`)) + ).toBe('exited') + }) + + it('is unverifiable for an unreadable PID record', () => { + expect(windowsProbe((dir) => writeFileSync(join(dir, RELAY_PID_FILENAME), 'x\n'))).toBe( + 'unverifiable' + ) + }) + + it('keeps the old rule without a PID file: no marker is never evidence of exit', () => { + expect(windowsProbe(() => {})).toBe('live') + }) +}) diff --git a/src/main/ssh/remote-node-runtime-store-gc.test.ts b/src/main/ssh/remote-node-runtime-store-gc.test.ts index c17cc897067..8af2693a8bb 100644 --- a/src/main/ssh/remote-node-runtime-store-gc.test.ts +++ b/src/main/ssh/remote-node-runtime-store-gc.test.ts @@ -351,17 +351,4 @@ describe('gcRemoteNodeRuntimeStore termination', () => { gcRemoteNodeRuntimeStore(conn, host, '/home/u', { currentPins: [sha('a')] }) ).rejects.toBe(error) }) - - it('skips Windows hosts without running anything', async () => { - const result = await gcRemoteNodeRuntimeStore( - conn, - getRemoteHostPlatform('win32-x64'), - 'C:/Users/u', - { - currentPins: [sha('a')] - } - ) - expect(result.state).toBe('skipped') - expect(mockExec).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ssh/remote-node-runtime-store-gc.ts b/src/main/ssh/remote-node-runtime-store-gc.ts index f0b26be16ae..c645b4eca8e 100644 --- a/src/main/ssh/remote-node-runtime-store-gc.ts +++ b/src/main/ssh/remote-node-runtime-store-gc.ts @@ -21,7 +21,6 @@ import { RUNTIME_STORE_ENTRY_NAME, RUNTIME_STORE_TOMBSTONE_NAME, RUNTIME_STORE_TOMBSTONE_PREFIX, - runtimeStoreInventoryCommand, type RuntimeStoreInventory } from './remote-node-runtime-store-inventory' import { @@ -38,6 +37,10 @@ import { restoreRemoteTreeCommand } from './ssh-remote-commands' import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { + hostRuntimeStoreInventoryCommand, + windowsSweepStaleRuntimeStagesCommand +} from './remote-node-runtime-store-windows' const MAX_REMOVALS_PER_PASS = 8 const ABANDONED_TOMBSTONE_MS = 30 * 60_000 @@ -127,8 +130,14 @@ const SWEPT_STAGE = 'SWEPT' * mtimes and not the directory's: an upload in flight keeps rewriting its archive, not the dir. * A `find` that cannot answer keeps the stage. */ -export function sweepStaleRuntimeStagesCommand(storeDir: string): string { +export function sweepStaleRuntimeStagesCommand( + storeDir: string, + host?: RemoteHostPlatform +): string { const staleMinutes = Math.ceil(INSTALL_LOCK_STALE_MS / 60_000) + if (host && isWindowsRemoteHost(host)) { + return windowsSweepStaleRuntimeStagesCommand(storeDir, staleMinutes, SWEPT_STAGE) + } return [ `for s in ${shellEscape(storeDir)}/${RUNTIME_STORE_STAGE_PREFIX}*; do`, ' [ -d "$s" ] && [ ! -L "$s" ] || continue', @@ -148,8 +157,14 @@ export function parseSweptRuntimeStages(output: string): string[] { .map((line) => line.slice(SWEPT_STAGE.length + 1)) } -function exec(conn: SshConnection, command: string, signal?: AbortSignal): Promise { - return execCommand(conn, command, { wrapCommand: true, signal }) +function exec( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + signal?: AbortSignal +): Promise { + // Why unwrapped on Windows: these are already self-contained powershell.exe command lines. + return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(host), signal }) } async function readInventory( @@ -160,7 +175,7 @@ async function readInventory( ): Promise { try { return parseRuntimeStoreInventory( - await exec(conn, runtimeStoreInventoryCommand(host, remoteHome), signal) + await exec(conn, host, hostRuntimeStoreInventoryCommand(host, remoteHome), signal) ) } catch (err) { if (isUnconfirmedSshCommandTermination(err)) { @@ -180,9 +195,6 @@ export async function gcRemoteNodeRuntimeStore( remoteHome: string, options: { currentPins: readonly string[]; signal?: AbortSignal } ): Promise { - if (isWindowsRemoteHost(host)) { - return { state: 'skipped', reason: 'Windows hosts have no managed runtime store yet' } - } const store = remoteNodeRuntimeStoreDir(host, remoteHome) const locked = await tryWithRuntimeStoreLock( conn, @@ -201,7 +213,12 @@ async function collectHoldingStoreLock( store: string, options: { currentPins: readonly string[]; signal?: AbortSignal } ): Promise { - const sweptStages = await exec(conn, sweepStaleRuntimeStagesCommand(store), options.signal) + const sweptStages = await exec( + conn, + host, + sweepStaleRuntimeStagesCommand(store, host), + options.signal + ) .then(parseSweptRuntimeStages) .catch((error: unknown) => { if (isUnconfirmedSshCommandTermination(error)) { @@ -276,7 +293,7 @@ async function moveTree( ): Promise { try { return ( - (await exec(conn, moveRemoteTreeCommand(host, source, destination), signal)).trim() === + (await exec(conn, host, moveRemoteTreeCommand(host, source, destination), signal)).trim() === 'MOVED' ) } catch (err) { @@ -294,11 +311,13 @@ async function restoreTree( entryDir: string, signal?: AbortSignal ): Promise { - await exec(conn, restoreRemoteTreeCommand(host, tombstone, entryDir), signal).catch((err) => { - if (isUnconfirmedSshCommandTermination(err)) { - throw err + await exec(conn, host, restoreRemoteTreeCommand(host, tombstone, entryDir), signal).catch( + (err) => { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } } - }) + ) } async function removeTree( @@ -308,7 +327,7 @@ async function removeTree( signal?: AbortSignal ): Promise { try { - await exec(conn, removeRemoteTreeCommand(host, path), signal) + await exec(conn, host, removeRemoteTreeCommand(host, path), signal) return true } catch (err) { if (isUnconfirmedSshCommandTermination(err)) { diff --git a/src/main/ssh/remote-node-runtime-store-inventory.ts b/src/main/ssh/remote-node-runtime-store-inventory.ts index 583ad230139..2c62dd4bb2a 100644 --- a/src/main/ssh/remote-node-runtime-store-inventory.ts +++ b/src/main/ssh/remote-node-runtime-store-inventory.ts @@ -14,9 +14,9 @@ import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' /** A version dir names a runtime it needs with an empty file of this prefix + sha (design D5). */ export const RUNTIME_REF_NODE_PREFIX = '.runtime-ref-node-' export const RUNTIME_STORE_TOMBSTONE_PREFIX = '.gc-tombstone-' -const INVENTORY_OK = '__ORCA_RUNTIME_STORE__OK' -const REFS_ERR = '__ORCA_RUNTIME_STORE__REFS_ERR' -const MAX_DIRS = 512 +export const INVENTORY_OK = '__ORCA_RUNTIME_STORE__OK' +export const REFS_ERR = '__ORCA_RUNTIME_STORE__REFS_ERR' +export const MAX_DIRS = 512 const SHA256 = /^[0-9a-f]{64}$/ export const RUNTIME_STORE_ENTRY_NAME = new RegExp( `^${ORCAD_NODE_RUNTIME_DIR_PREFIX}([0-9a-f]{64})$` @@ -24,8 +24,10 @@ export const RUNTIME_STORE_ENTRY_NAME = new RegExp( export const RUNTIME_STORE_TOMBSTONE_NAME = new RegExp( `^${RUNTIME_STORE_TOMBSTONE_PREFIX.replace(/\./g, '\\.')}${ORCAD_NODE_RUNTIME_DIR_PREFIX}([0-9a-f]{64})\\.[0-9]+\\.([0-9]+)$` ) +// Why `[/\\]`: Windows process paths use backslashes (see remote-node-runtime-store-windows.ts). const HELD_PATH = new RegExp( - `/${ORCAD_RUNTIMES_DIRNAME}/(?:${RUNTIME_STORE_TOMBSTONE_PREFIX.replace(/\./g, '\\.')})?${ORCAD_NODE_RUNTIME_DIR_PREFIX}([0-9a-f]{64})[./]` + `[/\\\\]${ORCAD_RUNTIMES_DIRNAME}[/\\\\](?:${RUNTIME_STORE_TOMBSTONE_PREFIX.replace(/\./g, '\\.')})?${ORCAD_NODE_RUNTIME_DIR_PREFIX}([0-9a-f]{64})[./\\\\]`, + 'i' ) export type RuntimeStoreInventory = { @@ -43,6 +45,7 @@ export type RuntimeStoreInventory = { export function runtimeStoreInventoryCommand(host: RemoteHostPlatform, remoteHome: string): string { const root = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) + const refPrefix = RUNTIME_REF_NODE_PREFIX return [ `root=${shellEscape(root)}`, @@ -126,7 +129,7 @@ export function parseRuntimeStoreInventory(output: string): RuntimeStoreInventor } else if (tag === 'VERIFIED' && RUNTIME_STORE_ENTRY_NAME.test(value)) { inventory.verifiedNewestFirst.push(value) } else if (tag === 'HOLD') { - const sha = HELD_PATH.exec(value)?.[1] + const sha = HELD_PATH.exec(value)?.[1]?.toLowerCase() if (sha) { inventory.held.add(sha) } diff --git a/src/main/ssh/remote-node-runtime-store-lock.ts b/src/main/ssh/remote-node-runtime-store-lock.ts index c0d923872a8..0acf0ea20c2 100644 --- a/src/main/ssh/remote-node-runtime-store-lock.ts +++ b/src/main/ssh/remote-node-runtime-store-lock.ts @@ -14,7 +14,7 @@ import { } from './ssh-relay-install-lock-commands' import { RELAY_REMOTE_DIR } from './relay-protocol' import { removeRemoteTreeCommand } from './ssh-remote-commands' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' export const RUNTIME_STORE_LOCK_NAME = '.store-lock' @@ -31,7 +31,9 @@ async function releaseRuntimeStoreLock( host: RemoteHostPlatform, storeDir: string ): Promise { - await execCommand(conn, removeRemoteTreeCommand(host, lockDir(host, storeDir))).catch((error) => { + await execCommand(conn, removeRemoteTreeCommand(host, lockDir(host, storeDir)), { + wrapCommand: !isWindowsRemoteHost(host) + }).catch((error) => { if (isUnconfirmedSshCommandTermination(error)) { throw error } @@ -47,14 +49,19 @@ async function tryAcquireRuntimeStoreLock( ): Promise { const lock = lockDir(host, storeDir) try { - const created = await execCommand(conn, tryCreateInstallLockCommand(host, lock), { signal }) + // Why unwrapped on Windows: these are already self-contained powershell.exe command lines. + const wrapCommand = !isWindowsRemoteHost(host) + const created = await execCommand(conn, tryCreateInstallLockCommand(host, lock), { + signal, + wrapCommand + }) if (created.trim().endsWith('OK')) { return true } const stolen = await execCommand( conn, tryStealInstallLockCommand(host, lock, INSTALL_LOCK_STALE_SECONDS), - { signal } + { signal, wrapCommand } ) return stolen.trim().endsWith('OK') } catch (error) { diff --git a/src/main/ssh/remote-node-runtime-store-windows.test.ts b/src/main/ssh/remote-node-runtime-store-windows.test.ts new file mode 100644 index 00000000000..91d584a4aee --- /dev/null +++ b/src/main/ssh/remote-node-runtime-store-windows.test.ts @@ -0,0 +1,267 @@ +import { spawn, spawnSync } from 'node:child_process' +import { copyFileSync, mkdirSync, mkdtempSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DeployHelpers from './ssh-relay-deploy-helpers' + +vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ + ...(await importOriginal()), + execCommand: vi.fn() +})) + +import type { SshConnection } from './ssh-connection' +import { gcRemoteNodeRuntimeStore, parseSweptRuntimeStages } from './remote-node-runtime-store-gc' +import { RUNTIME_STORE_LOCK_NAME } from './remote-node-runtime-store-lock' +import { parseRuntimeStoreInventory } from './remote-node-runtime-store-inventory' +import { + windowsRuntimeStoreInventoryCommand, + windowsSweepStaleRuntimeStagesCommand +} from './remote-node-runtime-store-windows' +import { execCommand } from './ssh-relay-deploy-helpers' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all connection access is replaced by execCommand's mock. +const conn = {} as SshConnection +const host = getRemoteHostPlatform('win32-x64') +const mockExec = vi.mocked(execCommand) +const sha = (c: string): string => c.repeat(64) +const root = 'C:/Users/ada/.orca-remote' +const store = `${root}/runtimes` + +const powerShell = [ + process.env.ORCA_POWERSHELL_EXECUTABLE, + ...(process.platform === 'win32' ? ['powershell.exe', 'pwsh.exe'] : ['pwsh']) +].find( + (candidate) => + candidate && + spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { + stdio: 'ignore' + }).status === 0 +) + +describe('Windows runtime store commands', () => { + it('stay inside the EDR posture: one encoded powershell.exe line, no policy switch, no compilation', () => { + for (const command of [ + windowsRuntimeStoreInventoryCommand(root), + windowsSweepStaleRuntimeStagesCommand(store, 20, 'SWEPT') + ]) { + expect(command).toMatch(/^powershell\.exe -NoProfile -NonInteractive -EncodedCommand \S+$/) + expect(decodeRemotePowerShellScript(command)).not.toMatch( + /ExecutionPolicy|Add-Type|\.ps1|Import-Module/ + ) + } + }) + + it('finds process holds by image path under runtimes\\, never by image name', () => { + const script = decodeRemotePowerShellScript(windowsRuntimeStoreInventoryCommand(root)) + expect(script).toContain( + `Get-CimInstance -ClassName Win32_Process -Filter "ExecutablePath LIKE '%\\\\runtimes\\\\%'" -Property ExecutablePath -ErrorAction Stop` + ) + expect(script).not.toMatch(/Name\s*=|node\.exe|Get-Process/) + // A failed query must not report that the check ran. + expect(script.indexOf("Write-Output 'PROCESS_CHECK cim'")).toBeGreaterThan( + script.indexOf('Get-CimInstance') + ) + }) + + it('reads both ref shapes from every sibling of runtimes\\', () => { + const script = decodeRemotePowerShellScript(windowsRuntimeStoreInventoryCommand(root)) + expect(script).toContain("Join-Path $d.FullName '.runtime-node'") + expect(script).toContain("$_.Name.StartsWith('.runtime-ref-node-')") + expect(script).toContain("$_.Name -ne 'runtimes'") + }) +}) + +describe('parseRuntimeStoreInventory with Windows output', () => { + it('takes holds from backslash image paths in any case, tombstones included', () => { + const inventory = parseRuntimeStoreInventory( + [ + 'DIR relay-0.1.0+abc', + `REF ${sha('a')}`, + `ENTRY node-${sha('a')}`, + `ENTRY node-${sha('b')}`, + `VERIFIED node-${sha('b')}`, + 'PROCESS_CHECK cim', + `HOLD C:\\Users\\ada\\.orca-remote\\Runtimes\\node-${sha('b').toUpperCase()}\\node.exe`, + `HOLD C:\\Users\\ada\\.orca-remote\\runtimes\\.gc-tombstone-node-${sha('c')}.1.2\\node.exe`, + 'HOLD C:\\Program Files\\nodejs\\node.exe', + '__ORCA_RUNTIME_STORE__OK' + ].join('\r\n') + ) + expect(inventory?.processCheckRan).toBe(true) + expect([...(inventory?.held ?? [])]).toEqual([sha('b'), sha('c')]) + expect([...(inventory?.referenced ?? [])]).toEqual([sha('a')]) + }) +}) + +describe('gcRemoteNodeRuntimeStore on a Windows host', () => { + beforeEach(() => { + mockExec.mockReset() + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('runs under the store lock with unwrapped powershell.exe commands and collects an idle runtime', async () => { + const scripts: string[] = [] + mockExec.mockImplementation(async (_conn, command, options) => { + expect(command).toMatch(/^powershell\.exe /) + expect(options).toMatchObject({ wrapCommand: false }) + const script = decodeRemotePowerShellScript(command) + scripts.push(script) + if (script.includes(RUNTIME_STORE_LOCK_NAME) && script.includes('CreateNew')) { + return 'OK' + } + if (script.includes('Win32_Process')) { + return [ + 'DIR relay-0.1.0+abc', + `REF ${sha('a')}`, + `ENTRY node-${sha('a')}`, + `ENTRY node-${sha('b')}`, + `ENTRY node-${sha('c')}`, + `VERIFIED node-${sha('a')}`, + `VERIFIED node-${sha('b')}`, + `VERIFIED node-${sha('c')}`, + 'PROCESS_CHECK cim', + '__ORCA_RUNTIME_STORE__OK' + ].join('\r\n') + } + if (script.includes('Move-Item')) { + return 'MOVED' + } + return '' + }) + + const result = await gcRemoteNodeRuntimeStore(conn, host, 'C:/Users/ada', { + currentPins: [sha('a')] + }) + + // `a` is pinned and referenced, `b` is the newest other verified runtime (keep two). + expect(result).toMatchObject({ state: 'collected', removed: [`node-${sha('c')}`] }) + const lockTaken = scripts.findIndex((s) => s.includes('CreateNew')) + const inventoried = scripts.findIndex((s) => s.includes('Win32_Process')) + const released = scripts.findLastIndex( + (s) => s.startsWith('Remove-Item') && s.includes(RUNTIME_STORE_LOCK_NAME) + ) + expect(lockTaken).toBe(0) + expect(inventoried).toBeGreaterThan(lockTaken) + expect(released).toBe(scripts.length - 1) + }) + + it('keeps everything when the process query did not run', async () => { + mockExec.mockImplementation(async (_conn, command) => { + const script = decodeRemotePowerShellScript(command) + if (script.includes('CreateNew')) { + return 'OK' + } + if (script.includes('Win32_Process')) { + return [ + `ENTRY node-${sha('c')}`, + `VERIFIED node-${sha('c')}`, + '__ORCA_RUNTIME_STORE__OK' + ].join('\n') + } + return '' + }) + const result = await gcRemoteNodeRuntimeStore(conn, host, 'C:/Users/ada', { + currentPins: [sha('a')] + }) + expect(result).toMatchObject({ state: 'collected', removed: [] }) + expect( + mockExec.mock.calls.some(([, command]) => + decodeRemotePowerShellScript(command).includes('Move-Item') + ) + ).toBe(false) + }) +}) + +describe.runIf(powerShell)('Windows runtime store commands (real PowerShell)', () => { + let home: string + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-win-store-')) + }) + afterEach(() => { + removeTreeSync(home) + }) + + function run(command: string): string { + const result = spawnSync( + powerShell!, + ['-NoProfile', '-NonInteractive', '-Command', decodeRemotePowerShellScript(command)], + { encoding: 'utf8' } + ) + expect(result.status, result.stderr).toBe(0) + return result.stdout + } + + it('inventories refs, entries and verified order', () => { + const remote = join(home, '.orca-remote') + const relay = join(remote, 'relay-0.1.0+abc') + mkdirSync(relay, { recursive: true }) + writeFileSync(join(relay, `.runtime-ref-node-${sha('a')}`), `${sha('a')}\n`) + for (const [c, age] of [ + ['a', 60], + ['b', 0] + ] as const) { + const entry = join(remote, 'runtimes', `node-${sha(c)}`) + mkdirSync(entry, { recursive: true }) + writeFileSync(join(entry, '.verified'), '') + const at = Date.now() / 1000 - age + utimesSync(join(entry, '.verified'), at, at) + } + const inventory = parseRuntimeStoreInventory(run(windowsRuntimeStoreInventoryCommand(remote))) + expect(inventory?.dirNames).toEqual(['relay-0.1.0+abc']) + expect([...(inventory?.referenced ?? [])]).toEqual([sha('a')]) + expect(inventory?.verifiedNewestFirst).toEqual([`node-${sha('b')}`, `node-${sha('a')}`]) + }) + + // Why Windows only: the hold comes from Win32_Process, which only Windows answers. + it.runIf(process.platform === 'win32')( + 'holds a runtime whose node.exe is running, found by its image path', + { timeout: 120_000 }, + async () => { + const remote = join(home, '.orca-remote') + const entry = join(remote, 'runtimes', `node-${sha('d')}`) + mkdirSync(entry, { recursive: true }) + const exe = join(entry, 'node.exe') + copyFileSync(process.execPath, exe) + const child = spawn(exe, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + try { + await new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', reject) + }) + const inventory = parseRuntimeStoreInventory( + run(windowsRuntimeStoreInventoryCommand(remote)) + ) + expect(inventory?.processCheckRan).toBe(true) + expect([...(inventory?.held ?? [])]).toEqual([sha('d')]) + } finally { + // Why wait: Windows will not delete the temp store while its image is still running. + if (child.pid !== undefined && child.exitCode === null) { + const exited = new Promise((resolve) => child.once('exit', resolve)) + child.kill() + await exited + } + } + } + ) + + it('sweeps only stages nothing has written to within the stale rule', () => { + const runtimes = join(home, 'runtimes') + const old = Date.now() / 1000 - 21 * 60 + const stage = (name: string, fileAge: number): string => { + const dir = join(runtimes, name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'node.zip'), 'x') + utimesSync(join(dir, 'node.zip'), fileAge, fileAge) + utimesSync(dir, old, old) + return dir + } + stage('.stage-node-x-1', old) + stage('.stage-node-x-2', Date.now() / 1000) + const out = run(windowsSweepStaleRuntimeStagesCommand(runtimes, 20, 'SWEPT')) + expect(parseSweptRuntimeStages(out)).toEqual(['.stage-node-x-1']) + }) +}) diff --git a/src/main/ssh/remote-node-runtime-store-windows.ts b/src/main/ssh/remote-node-runtime-store-windows.ts new file mode 100644 index 00000000000..fcca7083b97 --- /dev/null +++ b/src/main/ssh/remote-node-runtime-store-windows.ts @@ -0,0 +1,93 @@ +/** + * The Windows half of runtime store GC: the same inventory lines and stage sweep as the POSIX + * `sh` passes, each as ONE PowerShell invocation (docs/reference/windows-edr-posture.md). + * + * Process holds come from one `Get-CimInstance Win32_Process` query filtered on the image path + * under `runtimes\`, never on the image name: another program's node.exe must hold nothing. + */ +import { + ORCAD_NODE_RUNTIME_DIR_PREFIX, + ORCAD_NODE_RUNTIME_MARKER_FILENAME, + ORCAD_RUNTIMES_DIRNAME +} from '../../shared/orcad-artifacts' +import { RUNTIME_STORE_STAGE_PREFIX } from './orcad-remote-node-runtime' +import { + INVENTORY_OK, + MAX_DIRS, + REFS_ERR, + RUNTIME_REF_NODE_PREFIX, + RUNTIME_STORE_TOMBSTONE_PREFIX, + runtimeStoreInventoryCommand +} from './remote-node-runtime-store-inventory' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' + +const REPARSE = '[IO.FileAttributes]::ReparsePoint' + +/** The store inventory for either host dialect. */ +export function hostRuntimeStoreInventoryCommand( + host: RemoteHostPlatform, + remoteHome: string +): string { + return isWindowsRemoteHost(host) + ? windowsRuntimeStoreInventoryCommand(joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR)) + : runtimeStoreInventoryCommand(host, remoteHome) +} + +/** Same tags as `runtimeStoreInventoryCommand`; `root` is `~/.orca-remote`. */ +export function windowsRuntimeStoreInventoryCommand(root: string): string { + const fail = `Write-Output ${powerShellLiteral(REFS_ERR)}; exit 0` + return powerShellCommand( + [ + "$ProgressPreference = 'SilentlyContinue'", + `$root = ${powerShellLiteral(root)}`, + `$rt = Join-Path $root ${powerShellLiteral(ORCAD_RUNTIMES_DIRNAME)}`, + `if (-not (Test-Path -LiteralPath $rt -PathType Container)) { Write-Output ${powerShellLiteral(INVENTORY_OK)}; exit 0 }`, + // Why every sibling and not a prefix: a newer Orca's install may name a runtime too. + `try { $dirs = @(Get-ChildItem -LiteralPath $root -Force -Directory -ErrorAction Stop | Where-Object { $_.Name -ne ${powerShellLiteral(ORCAD_RUNTIMES_DIRNAME)} }) } catch { ${fail} }`, + `if ($dirs.Count -gt ${MAX_DIRS}) { ${fail} }`, + 'foreach ($d in $dirs) {', + "Write-Output ('DIR ' + $d.Name)", + `$marker = Join-Path $d.FullName ${powerShellLiteral(ORCAD_NODE_RUNTIME_MARKER_FILENAME)}`, + `if (Test-Path -LiteralPath $marker) { try { Write-Output ('REF ' + [IO.File]::ReadAllText($marker).Trim()) } catch { ${fail} } }`, + // Why StartsWith and not -Filter: -Filter also matches 8.3 short names. + `try { $refs = @(Get-ChildItem -LiteralPath $d.FullName -Force -ErrorAction Stop | Where-Object { (-not $_.PSIsContainer) -and $_.Name.StartsWith(${powerShellLiteral(RUNTIME_REF_NODE_PREFIX)}) }) } catch { ${fail} }`, + `foreach ($f in $refs) { Write-Output ('REF ' + $f.Name.Substring(${RUNTIME_REF_NODE_PREFIX.length})) }`, + '}', + `try { $entries = @(Get-ChildItem -LiteralPath $rt -Force -Directory -ErrorAction Stop | Where-Object { $_.Name.StartsWith(${powerShellLiteral(ORCAD_NODE_RUNTIME_DIR_PREFIX)}) -or $_.Name.StartsWith(${powerShellLiteral(`${RUNTIME_STORE_TOMBSTONE_PREFIX}${ORCAD_NODE_RUNTIME_DIR_PREFIX}`)}) }) } catch { ${fail} }`, + "foreach ($e in $entries) { Write-Output ('ENTRY ' + $e.Name) }", + `$verified = @($entries | Where-Object { $_.Name.StartsWith(${powerShellLiteral(ORCAD_NODE_RUNTIME_DIR_PREFIX)}) } | ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName '.verified') -Force -ErrorAction SilentlyContinue } | Where-Object { $null -ne $_ })`, + "foreach ($v in @($verified | Sort-Object LastWriteTimeUtc -Descending)) { Write-Output ('VERIFIED ' + $v.Directory.Name) }", + // Process checks only add holds, so a failed query keeps everything (no PROCESS_CHECK). + 'try {', + `$held = @(Get-CimInstance -ClassName Win32_Process -Filter "ExecutablePath LIKE '%\\\\${ORCAD_RUNTIMES_DIRNAME}\\\\%'" -Property ExecutablePath -ErrorAction Stop)`, + "Write-Output 'PROCESS_CHECK cim'", + "foreach ($p in $held) { if ($p.ExecutablePath) { Write-Output ('HOLD ' + $p.ExecutablePath) } }", + '} catch { }', + `Write-Output ${powerShellLiteral(INVENTORY_OK)}` + ].join('\n') + ) +} + +/** Mirrors `sweepStaleRuntimeStagesCommand`: a stage nothing has written to within the stale rule. */ +export function windowsSweepStaleRuntimeStagesCommand( + storeDir: string, + staleMinutes: number, + sweptTag: string +): string { + return powerShellCommand( + [ + "$ProgressPreference = 'SilentlyContinue'", + `$cutoff = [DateTime]::UtcNow.AddMinutes(-${staleMinutes})`, + `$stages = @(Get-ChildItem -LiteralPath ${powerShellLiteral(storeDir)} -Force -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith(${powerShellLiteral(RUNTIME_STORE_STAGE_PREFIX)}) })`, + 'foreach ($s in $stages) {', + `if ((($s.Attributes -band ${REPARSE}) -ne 0) -or ($s.LastWriteTimeUtc -ge $cutoff)) { continue }`, + // A listing that cannot answer keeps the stage. + 'try { $recent = @(Get-ChildItem -LiteralPath $s.FullName -Force -Recurse -ErrorAction Stop | Where-Object { $_.LastWriteTimeUtc -ge $cutoff } | Select-Object -First 1) } catch { continue }', + 'if ($recent.Count -gt 0) { continue }', + `try { Remove-Item -LiteralPath $s.FullName -Recurse -Force -ErrorAction Stop; Write-Output (${powerShellLiteral(`${sweptTag} `)} + $s.Name) } catch { }`, + '}' + ].join('\n') + ) +} diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index e416ab9e2f9..280635ec567 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -106,7 +106,8 @@ import { recoverOneStaleRelayUploadStageCommand, relayUploadStagePromotionConfirmed, RELAY_UPLOAD_STAGE_POOL_NAME, - reserveRelayUploadStageCommand + reserveRelayUploadStageCommand, + type WindowsUploadStageIdentity } from './ssh-relay-upload-stage-commands' import { isWindowsRemoteHost, @@ -575,9 +576,12 @@ async function deployAndLaunchRelayOnRuntime({ run } : undefined + let uploadStageIdentity: WindowsUploadStageIdentity | undefined if (pinnedContext?.plan.kind === 'pinned-node') { onProgress?.('Checking Orca Node runtime...') await ensurePinnedRelayRuntime({ ...pinnedContext, plan: pinnedContext.plan }, alreadyInstalled) + // Why: once node.exe is verified, stage fencing reads file IDs through it, not Add-Type (D5). + uploadStageIdentity = { node: prebuiltRelayNodePath(pinnedContext) } } // Why: derive the home-relative suffix once — recomputing it by stripping the shell home breaks on a split namespace. @@ -619,14 +623,24 @@ async function deployAndLaunchRelayOnRuntime({ await execHostCommand( conn, hostPlatform, - recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir), + recoverOneStaleRelayUploadStageCommand( + hostPlatform, + uploadStagePoolDir, + undefined, + uploadStageIdentity + ), { signal: deploySignal } ) const uploadStageOwner = createRelayInstallMarkerFileName() const reservation = await execHostCommand( conn, hostPlatform, - reserveRelayUploadStageCommand(hostPlatform, uploadStagePoolDir, uploadStageOwner), + reserveRelayUploadStageCommand( + hostPlatform, + uploadStagePoolDir, + uploadStageOwner, + uploadStageIdentity + ), { signal: deploySignal } ) const uploadStage = parseReservedRelayUploadStage( @@ -683,7 +697,8 @@ async function deployAndLaunchRelayOnRuntime({ hostPlatform, uploadStage, uploadStageOwner, - remoteRelayDir + remoteRelayDir, + uploadStageIdentity ), { signal: deploySignal } ) @@ -733,7 +748,12 @@ async function deployAndLaunchRelayOnRuntime({ await execHostCommand( conn, hostPlatform, - cleanupOwnedRelayUploadStageCommand(hostPlatform, uploadStage, uploadStageOwner) + cleanupOwnedRelayUploadStageCommand( + hostPlatform, + uploadStage, + uploadStageOwner, + uploadStageIdentity + ) ).catch((error) => { if (isUnconfirmedSshCommandTermination(error)) { throw error @@ -798,6 +818,7 @@ async function deployAndLaunchRelayOnRuntime({ ripgrepSettled ? ensureRemoteOpenCodeRuntime(conn, hostPlatform, remoteHome, { nodePath: launched.nodePath, + verifiedNodePath: uploadStageIdentity?.node, relayDir: remoteRelayDir, signal: deploySignal }) @@ -817,7 +838,12 @@ async function deployAndLaunchRelayOnRuntime({ execHostCommand( conn, hostPlatform, - recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir) + recoverOneStaleRelayUploadStageCommand( + hostPlatform, + uploadStagePoolDir, + undefined, + uploadStageIdentity + ) ) .catch((error) => { if (isUnconfirmedSshCommandTermination(error)) { @@ -907,6 +933,7 @@ async function deployAndLaunchRelayOnRuntime({ (signal) => ensureRemoteOpenCodeRuntime(conn, hostPlatform, remoteHome, { nodePath: launched.nodePath, + verifiedNodePath: uploadStageIdentity?.node, relayDir: remoteRelayDir, signal }) diff --git a/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts b/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts index c61f32f4116..0e68172c27a 100644 --- a/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts +++ b/src/main/ssh/ssh-relay-opencode-pinned-node.test.ts @@ -1,48 +1,35 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeRuntimeStore from './orcad-remote-node-runtime' -import type * as RuntimeCommands from './ssh-relay-opencode-runtime-commands' const mocks = vi.hoisted(() => ({ target: vi.fn(), archive: vi.fn(), - executable: vi.fn(), - ensure: vi.fn(), - probe: vi.fn((_args: unknown) => 'cache-probe') + ensure: vi.fn() })) vi.mock('./orcad-deployment-target', () => ({ resolveOrcadDeploymentTarget: mocks.target })) vi.mock('./pinned-runtime-materializer', () => ({ - materializeNodeRuntimeArchive: mocks.archive, - materializeCachedNodeRuntime: mocks.executable + materializeNodeRuntimeArchive: mocks.archive })) vi.mock('./orcad-remote-node-runtime', async (original) => ({ ...(await original()), ensureRemoteOrcadNodeRuntime: mocks.ensure })) -vi.mock('./ssh-relay-opencode-runtime-commands', async (original) => ({ - ...(await original()), - probeOpenCodeRuntimeCacheCommand: mocks.probe -})) import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' import type { SshConnection } from './ssh-connection' import { getRemoteHostPlatform } from './ssh-remote-platform' -import { OPENCODE_RUNTIME_RESULT } from './ssh-relay-opencode-runtime-commands' import { preparePinnedNodeForVault } from './ssh-relay-opencode-pinned-node' // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every remote call is mocked; the connection is only passed through. const conn = {} as SshConnection -const frame = (status: string, executable?: string) => - `${OPENCODE_RUNTIME_RESULT}${JSON.stringify({ status, executable })}\n` function prepare(platform: 'linux-x64' | 'win32-x64', exec: (command: string) => Promise) { const relayDir = `${platform === 'win32-x64' ? 'C:/Users/ada' : '/home/ada'}/.orca-remote/relay-build` return preparePinnedNodeForVault({ conn, host: getRemoteHostPlatform(platform), - nodePath: 'node', relayDir, cacheRoot: '/cache', - referencePath: `${relayDir}/opencode-sqlite-runtime.json`, signal: new AbortController().signal, exec, remote: (operation) => operation() @@ -54,48 +41,31 @@ beforeEach(() => { }) describe('pinned Node for the SSH vault reader', () => { - it('uses the shared runtimes/ store on POSIX hosts and fetches the archive only on demand', async () => { - mocks.target.mockResolvedValue('linux-x64-glibc') - mocks.ensure.mockImplementation(async (options) => { - expect(options).toMatchObject({ slotDir: '/home/ada/.orca-remote/relay-build' }) - await options.archivePath() - return { executable: '/home/ada/.orca-remote/runtimes/node-x/bin/node', transfer: 'cached' } - }) - mocks.archive.mockResolvedValue('/cache/node.tar.gz') + it.each([ + ['linux-x64', 'linux-x64-glibc', '/home/ada', 'bin/node'], + ['win32-x64', 'win32-x64', 'C:/Users/ada', 'node.exe'] + ] as const)( + 'installs the official archive into the shared runtimes/ store on %s hosts', + async (platform, target, home, executableName) => { + const sha = NODE_RUNTIME_ASSETS[target].executableSha256 + const executable = `${home}/.orca-remote/runtimes/node-${sha}/${executableName}` + mocks.target.mockResolvedValue(target) + mocks.ensure.mockImplementation(async (options) => { + expect(options).toMatchObject({ slotDir: `${home}/.orca-remote/relay-build`, target }) + await options.archivePath() + return { executable, transfer: 'uploaded' } + }) + mocks.archive.mockResolvedValue('/cache/node-archive') - expect(await prepare('linux-x64', vi.fn())).toEqual({ - executable: '/home/ada/.orca-remote/runtimes/node-x/bin/node' - }) - expect(mocks.archive).toHaveBeenCalledWith('linux-x64-glibc', '/cache', expect.any(Object)) - expect(mocks.executable).not.toHaveBeenCalled() - }) + expect(await prepare(platform, vi.fn())).toEqual({ executable, runtimeSha256: sha }) + expect(mocks.archive).toHaveBeenCalledWith(target, '/cache', expect.any(Object)) + } + ) - it('hands Windows hosts a verified node.exe under the same store layout', async () => { + it('fetches no archive when the host store already has a verified runtime', async () => { mocks.target.mockResolvedValue('win32-x64') - mocks.executable.mockResolvedValue('/cache/node/sha/node.exe') - const expected = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 - const exec = vi.fn(async () => frame('missing')) - - const result = await prepare('win32-x64', exec) - - const executable = `C:/Users/ada/.orca-remote/runtimes/node-${expected}/node.exe` - expect(result).toEqual({ - executable, - upload: { localRuntime: '/cache/node/sha/node.exe', expectedHash: expected } - }) - expect(mocks.probe).toHaveBeenCalledWith( - expect.objectContaining({ executable, expectedHash: expected }) - ) - expect(mocks.ensure).not.toHaveBeenCalled() + mocks.ensure.mockResolvedValue({ executable: 'C:/x/node.exe', transfer: 'cached' }) + await prepare('win32-x64', vi.fn()) expect(mocks.archive).not.toHaveBeenCalled() }) - - it('reuses a Windows runtime the host already verified', async () => { - mocks.target.mockResolvedValue('win32-x64') - const cached = 'C:/Users/ada/.orca-remote/runtimes/node-x/repair-1/node.exe' - expect(await prepare('win32-x64', async () => frame('ready', cached))).toEqual({ - executable: cached - }) - expect(mocks.executable).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ssh/ssh-relay-opencode-pinned-node.ts b/src/main/ssh/ssh-relay-opencode-pinned-node.ts index 7f4f90adcab..155fa8856ce 100644 --- a/src/main/ssh/ssh-relay-opencode-pinned-node.ts +++ b/src/main/ssh/ssh-relay-opencode-pinned-node.ts @@ -2,103 +2,55 @@ import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' -import { ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE } from '../../shared/orcad-artifacts' import type { SshConnection } from './ssh-connection' import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' -import { - ensureRemoteOrcadNodeRuntime, - remoteNodeRuntimeDir, - type RemoteRuntimeStep -} from './orcad-remote-node-runtime' -import { - materializeCachedNodeRuntime, - materializeNodeRuntimeArchive -} from './pinned-runtime-materializer' -import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { - parseOpenCodeRuntimeResult, - probeOpenCodeRuntimeCacheCommand -} from './ssh-relay-opencode-runtime-commands' +import { ensureRemoteOrcadNodeRuntime, type RemoteRuntimeStep } from './orcad-remote-node-runtime' +import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' +import type { RemoteHostPlatform } from './ssh-remote-platform' const DOWNLOAD_TIMEOUT_MS = 180_000 const downloads = new Map>() -/** A verified local executable the caller still has to upload and promote on the host. */ -export type PinnedNodeVaultUpload = { localRuntime: string; expectedHash: string } - /** - * The pinned Node for hosts whose own Node cannot read OpenCode's database (design D4a). - * POSIX hosts get it in the shared runtimes/ store; nothing here touches vault-sqlite/, which - * old relays' references still name. + * The pinned Node for hosts whose own Node cannot read OpenCode's database (design D4a). Every + * host, Windows included, installs it into the shared runtimes/ store as the official archive + * with a `.verified` marker; nothing here touches vault-sqlite/, which old relays' references + * still name. `runtimeSha256` is the ref the relay dir must carry so store GC keeps it. */ export async function preparePinnedNodeForVault(options: { conn: SshConnection host: RemoteHostPlatform - nodePath: string relayDir: string cacheRoot?: string - referencePath: string signal: AbortSignal exec: (command: string) => Promise remote: RemoteRuntimeStep -}): Promise<{ executable: string; upload?: PinnedNodeVaultUpload }> { +}): Promise<{ executable: string; runtimeSha256: string }> { const { conn, host, signal, exec } = options const target = await resolveOrcadDeploymentTarget({ conn, host, signal, exec }) const cacheRoot = options.cacheRoot ?? join(getAppEnvironment().getPath('userData'), 'orcad-artifacts') - if (!isWindowsRemoteHost(host)) { - const { executable } = await ensureRemoteOrcadNodeRuntime({ - conn, - host, - slotDir: options.relayDir, - target, - archivePath: () => cachedRuntime('archive', target, cacheRoot, signal), - signal, - remoteStep: options.remote - }) - return { executable } - } - // Why a bare executable: Windows hosts get archive extraction with the upload path (design D5). - const expectedHash = NODE_RUNTIME_ASSETS[target].executableSha256 - const executable = joinRemotePath( + const { executable } = await ensureRemoteOrcadNodeRuntime({ + conn, host, - remoteNodeRuntimeDir(host, options.relayDir, target), - ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE - ) - const cached = parseOpenCodeRuntimeResult( - await exec( - probeOpenCodeRuntimeCacheCommand({ - host, - nodePath: options.nodePath, - executable, - expectedHash, - reference: options.referencePath - }) - ) - ) - if (cached.status === 'ready' && cached.executable) { - return { executable: cached.executable } - } - if (cached.status !== 'missing') { - throw new Error('The host did not confirm its SQLite runtime cache.') - } - const localRuntime = await cachedRuntime('executable', target, cacheRoot, signal) - signal.throwIfAborted() - return { executable, upload: { localRuntime, expectedHash } } + slotDir: options.relayDir, + target, + archivePath: () => cachedArchive(target, cacheRoot, signal), + signal, + remoteStep: options.remote + }) + return { executable, runtimeSha256: NODE_RUNTIME_ASSETS[target].executableSha256 } } -function cachedRuntime( - kind: 'archive' | 'executable', +function cachedArchive( target: ServerTarget, cacheRoot: string, signal: AbortSignal ): Promise { - const key = `${cacheRoot}\0${kind}\0${target}` + const key = `${cacheRoot}\0${target}` let pending = downloads.get(key) if (!pending) { - const materialize = - kind === 'archive' ? materializeNodeRuntimeArchive : materializeCachedNodeRuntime - pending = materialize(target, cacheRoot, { + pending = materializeNodeRuntimeArchive(target, cacheRoot, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }).finally(() => downloads.delete(key)) downloads.set(key, pending) diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts index 3b45f9f7193..52938d6f239 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.test.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto' import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,9 +7,7 @@ import { getRemoteHostPlatform } from './ssh-remote-platform' import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { parseOpenCodeRuntimeResult, - probeOpenCodeRuntimeCacheCommand, probeOpenCodeNodeSqliteCommand, - promoteOpenCodeRuntimeCommand, publishOpenCodeRuntimeReferenceCommand } from './ssh-relay-opencode-runtime-commands' import { @@ -23,7 +20,7 @@ import { const host = getRemoteHostPlatform('linux-x64') const nodePath = process.execPath const directories: string[] = [] -const expectedHash = createHash('sha256').update('verified runtime').digest('hex') +const runtimeSha = 'a'.repeat(64) const markerName = '.sftp-namespace-0123456789abcdef0123456789abcdef' afterEach(async () => { @@ -103,39 +100,13 @@ describe.skipIf(process.platform === 'win32')('host-owned SQLite setup commands' }) }) - it('stages, verifies by bytes, promotes and atomically publishes under quoted paths', async () => { + it('publishes the reference atomically through a staged file under quoted paths', async () => { const root = await directory() const stage = await reserveStage(root) - const stageDir = stage.slotDir - const executable = join(root, expectedHash, 'bun') - const prepared = await command( - probeOpenCodeRuntimeCacheCommand({ - host, - nodePath, - executable, - expectedHash, - reference: join(root, 'runtime.json') - }) - ) - expect(parseOpenCodeRuntimeResult(prepared.stdout).status).toBe('missing') - expect((await stat(join(stageDir, markerName))).isFile()).toBe(true) - const stagedBinary = join(stageDir, 'payload', 'bun') - await writeFile(stagedBinary, 'verified runtime') - const promoted = await command( - promoteOpenCodeRuntimeCommand({ - host, - nodePath, - stagedBinary, - executable, - expectedHash, - repairToken: 'repair' - }) - ) - expect(parseOpenCodeRuntimeResult(promoted.stdout)).toEqual({ status: 'ready', executable }) - expect(await readFile(executable, 'utf8')).toBe('verified runtime') + const executable = join(root, 'runtimes', `node-${runtimeSha}`, 'bin', 'node') const reference = join(root, 'opencode-sqlite-runtime.json') await writeFile(reference, '{"old":true}') - const stagedReference = join(stageDir, 'payload', 'ref.json') + const stagedReference = join(stage.slotDir, 'payload', 'ref.json') await writeFile(stagedReference, JSON.stringify({ protocol: 1, executable })) const published = await command( publishOpenCodeRuntimeReferenceCommand({ @@ -148,67 +119,30 @@ describe.skipIf(process.platform === 'win32')('host-owned SQLite setup commands' ) expect(parseOpenCodeRuntimeResult(published.stdout).status).toBe('published') expect(JSON.parse(await readFile(reference, 'utf8'))).toEqual({ protocol: 1, executable }) - await command(cleanupOwnedRelayUploadStageCommand(host, stage, markerName)) - await expect(stat(stageDir)).rejects.toMatchObject({ code: 'ENOENT' }) - expect(await readFile(executable, 'utf8')).toBe('verified runtime') - }) - - it('refuses equal-sized corrupt uploads instead of accepting a size match', async () => { - const root = await directory() - const stagedBinary = join(root, 'source') - const executable = join(root, 'installed', 'bun') - await writeFile(stagedBinary, 'corrupt! runtime') - expect((await stat(stagedBinary)).size).toBe(Buffer.byteLength('verified runtime')) - const result = await command( - promoteOpenCodeRuntimeCommand({ - host, - nodePath, - stagedBinary, - executable, - expectedHash, - repairToken: 'one' - }) - ) - expect(result.code).not.toBe(0) - expect(result.stderr).toContain('checksum mismatch') - await expect(stat(executable)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('preserves an existing corrupt binary and reuses its verified repair reference', async () => { - const root = await directory() - const executable = join(root, expectedHash, 'bun') - await mkdir(join(root, expectedHash)) - await writeFile(executable, 'old binary still owned by another process') - const stagedBinary = join(root, 'source') - await writeFile(stagedBinary, 'verified runtime') - const promoted = await command( - promoteOpenCodeRuntimeCommand({ - host, - nodePath, - stagedBinary, - executable, - expectedHash, - repairToken: 'two' - }) - ) - const repaired = join(root, expectedHash, 'repair-two', 'bun') - expect(parseOpenCodeRuntimeResult(promoted.stdout).executable).toBe(repaired) - expect(await readFile(executable, 'utf8')).toBe('old binary still owned by another process') - const reference = join(root, 'runtime.json') - await writeFile(reference, JSON.stringify({ protocol: 1, executable: repaired })) - const prepared = await command( - probeOpenCodeRuntimeCacheCommand({ - host, - nodePath, - executable, - expectedHash, - reference - }) - ) - expect(parseOpenCodeRuntimeResult(prepared.stdout)).toEqual({ - status: 'ready', - executable: repaired + await expect(stat(join(root, `.runtime-ref-node-${runtimeSha}`))).rejects.toMatchObject({ + code: 'ENOENT' }) + await command(cleanupOwnedRelayUploadStageCommand(host, stage, markerName)) + await expect(stat(stage.slotDir)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('writes the store ref that holds a pinned runtime before the reference names it', async () => { + const root = await directory() + const stagedReference = join(root, 'staged.json') + await writeFile(stagedReference, '{"protocol":1}') + const ref = join(root, `.runtime-ref-node-${runtimeSha}`) + const published = await command( + publishOpenCodeRuntimeReferenceCommand({ + host, + nodePath, + stagedReference, + reference: join(root, 'opencode-sqlite-runtime.json'), + token: 'two', + runtimeRef: { path: ref, sha256: runtimeSha } + }) + ) + expect(parseOpenCodeRuntimeResult(published.stdout).status).toBe('published') + expect(await readFile(ref, 'utf8')).toBe(`${runtimeSha}\n`) }) it('defers an empty host, honors database overrides, and ignores in-memory databases', async () => { @@ -242,54 +176,27 @@ describe.skipIf(process.platform === 'win32')('host-owned SQLite setup commands' await expect(stat(abandoned.slotDir)).rejects.toMatchObject({ code: 'ENOENT' }) expect(await readFile(join(fresh.slotDir, 'payload', 'bun'), 'utf8')).toBe('active upload') }) - - it('falls back to an atomic unique rename when the host filesystem rejects hard links', async () => { - const root = await directory() - const source = join(root, 'source') - await writeFile(source, 'verified runtime') - const preload = join(root, 'disable-hardlinks.cjs') - await writeFile( - preload, - "require('node:fs').promises.link=async()=>{throw Object.assign(Error('unsupported'),{code:'EPERM'})}" - ) - const executable = join(root, expectedHash, 'bun') - const result = await command( - promoteOpenCodeRuntimeCommand({ - host, - nodePath, - stagedBinary: source, - executable, - expectedHash, - repairToken: 'fallback' - }), - { NODE_OPTIONS: `--require ${JSON.stringify(preload)}` } - ) - expect(result.code, result.stderr).toBe(0) - const repaired = join(root, expectedHash, 'repair-fallback', 'bun') - expect(parseOpenCodeRuntimeResult(result.stdout)).toEqual({ - status: 'ready', - executable: repaired - }) - expect(await readFile(repaired, 'utf8')).toBe('verified runtime') - await expect(stat(source)).rejects.toMatchObject({ code: 'ENOENT' }) - }) }) it('carries Windows JavaScript and path arguments through the established PowerShell encoder', () => { const windows = getRemoteHostPlatform('win32-x64') - const command = promoteOpenCodeRuntimeCommand({ + const command = publishOpenCodeRuntimeReferenceCommand({ host: windows, nodePath: "C:/Program Files/O'Brien/node.exe", - stagedBinary: 'C:/Users/a & b/.upload/bun.exe', - executable: 'C:/Users/a & b/cache/bun.exe', - expectedHash, - repairToken: 'one' + stagedReference: 'C:/Users/a & b/.upload/ref.json', + reference: 'C:/Users/a & b/.orca-remote/relay-x/opencode-sqlite-runtime.json', + token: 'one', + runtimeRef: { + path: `C:/Users/a & b/.orca-remote/relay-x/.runtime-ref-node-${runtimeSha}`, + sha256: runtimeSha + } }) const decoded = decodeRemotePowerShellScript(command) expect(decoded).toContain("& 'C:/Program Files/O''Brien/node.exe'") - expect(decoded).toContain('createHash') - expect(decoded).toContain('C:/Users/a & b/.upload/bun.exe') + expect(decoded).toContain('C:/Users/a & b/.upload/ref.json') + expect(decoded).toContain(`.runtime-ref-node-${runtimeSha}`) expect(command).not.toContain('-ExecutionPolicy') + expect(decoded).not.toContain('Add-Type') }) it('rejects missing or malformed host confirmations', () => { diff --git a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts index 9026b9cb1fd..419e90d127d 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime-commands.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime-commands.ts @@ -21,9 +21,6 @@ function nodeCommand( } const SEND = `const send=(value)=>console.log(${JSON.stringify(OPENCODE_RUNTIME_RESULT)}+JSON.stringify(value));` -const HASH = `const fs=require('node:fs');const fsp=fs.promises;const path=require('node:path'); -async function hash(file){try{const digest=require('node:crypto').createHash('sha256');for await(const chunk of fs.createReadStream(file))digest.update(chunk);return digest.digest('hex')}catch(error){if(error.code==='ENOENT')return null;throw error}} -` /** Host Node needs backup() too: 22.13-22.15 have DatabaseSync without it (design D4). */ export function probeOpenCodeNodeSqliteCommand( @@ -51,75 +48,34 @@ send({status:'ready',executable:process.execPath})}catch{send({status:'unsupport ) } -export function probeOpenCodeRuntimeCacheCommand(args: { - host: RemoteHostPlatform - nodePath: string - executable: string - expectedHash: string - reference: string -}): string { - return nodeCommand( - args.host, - args.nodePath, - `${HASH}${SEND} -(async()=>{const [executable,expected,reference]=process.argv.slice(1); -let candidate=executable;let digest=candidate?await hash(candidate):null; -if(candidate&&digest!==expected){try{const ref=JSON.parse(await fsp.readFile(reference,'utf8')); -const relative=path.relative(path.dirname(executable),ref.executable); -if(ref.protocol===1&&relative&&!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative)){candidate=ref.executable;digest=await hash(candidate)}}catch{}} -if(candidate&&digest===expected){if(process.platform!=='win32')await fsp.chmod(candidate,448);send({status:'ready',executable:candidate});return} -send({status:'missing'})})().catch(error=>{console.error(error.message);process.exitCode=1})`, - [args.executable, args.expectedHash, args.reference] - ) -} - -export function promoteOpenCodeRuntimeCommand(args: { - host: RemoteHostPlatform - nodePath: string - stagedBinary: string - executable: string - expectedHash: string - repairToken: string -}): string { - return nodeCommand( - args.host, - args.nodePath, - `${HASH}${SEND} -(async()=>{const [source,destination,expected,token]=process.argv.slice(1); -if(await hash(source)!==expected)throw Error('Uploaded SQLite runtime checksum mismatch'); -let executable=destination;const existing=await hash(destination); -if(existing!==expected){ -if(existing!==null)executable=path.join(path.dirname(destination),'repair-'+token,path.basename(destination)); -await fsp.mkdir(path.dirname(executable),{recursive:true,mode:448}); -if(process.platform!=='win32')await fsp.chmod(source,448); -try{await fsp.link(source,executable)}catch(error){if(await hash(executable)!==expected){ -if(!['EPERM','EOPNOTSUPP','ENOTSUP','ENOSYS','EXDEV'].includes(error.code))throw error; -executable=path.join(path.dirname(destination),'repair-'+token,path.basename(destination)); -await fsp.mkdir(path.dirname(executable),{recursive:true,mode:448});await fsp.rename(source,executable) -}} -} -send({status:'ready',executable})})().catch(error=>{console.error(error.message);process.exitCode=1})`, - [args.stagedBinary, args.executable, args.expectedHash, args.repairToken] - ) -} - +/** + * Publishes the reference. With `runtimeRef`, the relay dir first gains the store ref file that + * keeps the pinned runtime from store GC, so the reference never names an unheld runtime. + */ export function publishOpenCodeRuntimeReferenceCommand(args: { host: RemoteHostPlatform nodePath: string stagedReference: string reference: string token: string + runtimeRef?: { path: string; sha256: string } }): string { return nodeCommand( args.host, args.nodePath, `${SEND} const fs=require('node:fs/promises');const path=require('node:path'); -(async()=>{const [source,destination,token]=process.argv.slice(1);const temporary=destination+'.upload-'+token; -try{await fs.copyFile(source,temporary,require('node:fs').constants.COPYFILE_EXCL); +(async()=>{const [source,destination,token,refPath,refSha]=process.argv.slice(1);const temporary=destination+'.upload-'+token; +try{if(refPath)await fs.writeFile(refPath,refSha+'\\n'); +await fs.copyFile(source,temporary,require('node:fs').constants.COPYFILE_EXCL); await fs.rename(temporary,destination);send({status:'published'})} finally{await fs.rm(temporary,{force:true})}})().catch(error=>{console.error(error.message);process.exitCode=1})`, - [args.stagedReference, args.reference, args.token] + [ + args.stagedReference, + args.reference, + args.token, + ...(args.runtimeRef ? [args.runtimeRef.path, args.runtimeRef.sha256] : []) + ] ) } diff --git a/src/main/ssh/ssh-relay-opencode-runtime.test.ts b/src/main/ssh/ssh-relay-opencode-runtime.test.ts index 9dfa82c14fc..56be92a964d 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime.test.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime.test.ts @@ -36,6 +36,7 @@ import { getRemoteHostPlatform } from './ssh-remote-platform' import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' import { ensureRemoteOpenCodeRuntime } from './ssh-relay-opencode-runtime' import { OPENCODE_RUNTIME_RESULT } from './ssh-relay-opencode-runtime-commands' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' const host = getRemoteHostPlatform('linux-x64') const remoteHome = '/home/ada' @@ -413,3 +414,85 @@ describe('SSH OpenCode runtime setup', () => { expect(mocks.materialize).not.toHaveBeenCalled() }) }) + +describe('SSH OpenCode runtime setup on a Windows host', () => { + const windows = getRemoteHostPlatform('win32-x64') + const home = 'C:/Users/ada' + const windowsRelayDir = `${home}/.orca-remote/relay-build` + const sha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 + const storeNode = `${home}/.orca-remote/runtimes/node-${sha}/node.exe` + + function answerWindows(storeReady: boolean): string[] { + const scripts: string[] = [] + mocks.target.mockResolvedValue('win32-x64') + mocks.exec.mockImplementation(async (_conn, command: string) => { + const script = decodeRemotePowerShellScript(command) + scripts.push(script) + if (script.includes('SELECT 1 AS ready')) { + return frame('unsupported') + } + if (script.includes('.store-lock') && script.includes('CreateNew')) { + return 'OK' + } + if (script.includes('Invoke-OrcaPromote')) { + return 'ORCA_NODE_RUNTIME_READY' + } + if (script.includes('ORCA_NODE_RUNTIME_MISSING')) { + return storeReady ? 'ORCA_NODE_RUNTIME_READY' : 'ORCA_NODE_RUNTIME_MISSING' + } + if (script.includes('staging quota is full')) { + return `__ORCA_UPLOAD_STAGE_SLOT__${script.match(/\.sftp-namespace-[0-9a-f]{32}/)?.[0]}:slot-0` + } + if (script.includes('COPYFILE_EXCL')) { + return frame('published') + } + return '' + }) + return scripts + } + + it('installs the official archive through the runtime store, not a client-extracted node.exe', async () => { + const scripts = answerWindows(false) + expect( + await ensureRemoteOpenCodeRuntime(connection(true), windows, home, { + nodePath: 'C:/Program Files/nodejs/node.exe', + relayDir: windowsRelayDir, + cacheRoot + }) + ).toBe('ready') + expect(mocks.materialize).toHaveBeenCalledWith('win32-x64', cacheRoot, expect.any(Object)) + expect(mocks.upload).toHaveBeenCalledOnce() + expect(mocks.upload.mock.calls[0][2]).toMatch( + /\/runtimes\/\.stage-node-[0-9a-f]{64}-[0-9a-f]{16}$/ + ) + expect(scripts.some((script) => script.includes('Invoke-OrcaPromote'))).toBe(true) + expect(JSON.parse(mocks.write.mock.calls[0][3])).toEqual({ protocol: 1, executable: storeNode }) + const publish = scripts.find((script) => script.includes('COPYFILE_EXCL')) + expect(publish).toContain(`${windowsRelayDir}/.runtime-ref-node-${sha}`) + // Stage fencing reads file IDs through the verified node.exe, so no script compiles C#. + const stageScripts = scripts.filter((script) => script.includes('$getFileIdentity')) + expect(stageScripts.length).toBeGreaterThan(0) + for (const script of stageScripts) { + expect(script).toContain(`$orcaIdentityNode = '${storeNode}'`) + expect(script).not.toContain('Add-Type') + } + }) + + it("reuses a verified store runtime and prefers the relay's own verified node.exe", async () => { + const scripts = answerWindows(true) + const relayNode = `${home}/.orca-remote/runtimes/node-other/node.exe` + expect( + await ensureRemoteOpenCodeRuntime(connection(true), windows, home, { + nodePath: relayNode, + verifiedNodePath: relayNode, + relayDir: windowsRelayDir, + cacheRoot + }) + ).toBe('ready') + expect(mocks.materialize).not.toHaveBeenCalled() + expect(mocks.upload).not.toHaveBeenCalled() + for (const script of scripts.filter((s) => s.includes('$getFileIdentity'))) { + expect(script).toContain(`$orcaIdentityNode = '${relayNode}'`) + } + }) +}) diff --git a/src/main/ssh/ssh-relay-opencode-runtime.ts b/src/main/ssh/ssh-relay-opencode-runtime.ts index 8ea6f8fa344..f51343cc683 100644 --- a/src/main/ssh/ssh-relay-opencode-runtime.ts +++ b/src/main/ssh/ssh-relay-opencode-runtime.ts @@ -1,16 +1,11 @@ import { randomBytes } from 'node:crypto' -import { copyFile, link, mkdtemp, rm } from 'node:fs/promises' -import { dirname, join } from 'node:path' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE } from '../../shared/orcad-artifacts' import type { SshConnection } from './ssh-connection' import type { RemoteRuntimeStep } from './orcad-remote-node-runtime' -import { - preparePinnedNodeForVault, - type PinnedNodeVaultUpload -} from './ssh-relay-opencode-pinned-node' +import { preparePinnedNodeForVault } from './ssh-relay-opencode-pinned-node' +import { RUNTIME_REF_NODE_PREFIX } from './remote-node-runtime-store-inventory' import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' +import { writeRelayFile } from './ssh-relay-install-transfers' import { createRelayUploadStageNamespace, relayUploadStageSftpNamespaceMapping @@ -28,7 +23,6 @@ import { import { parseOpenCodeRuntimeResult, probeOpenCodeNodeSqliteCommand, - promoteOpenCodeRuntimeCommand, publishOpenCodeRuntimeReferenceCommand } from './ssh-relay-opencode-runtime-commands' @@ -46,6 +40,8 @@ const installations = new WeakMap< type SetupOptions = { nodePath: string + /** The relay's verified pinned node.exe, if any; stage fencing then needs no Add-Type (D5). */ + verifiedNodePath?: string relayDir: string signal?: AbortSignal cacheRoot?: string @@ -156,21 +152,28 @@ async function install( throw new Error('The host did not complete its SQLite read probe.') } let executable = node.executable - let upload: PinnedNodeVaultUpload | undefined + let runtimeRef: { path: string; sha256: string } | undefined + let identityNode = options.verifiedNodePath if (node.status === 'unsupported') { const pinned = await preparePinnedNodeForVault({ conn, host, - nodePath: options.nodePath, relayDir: options.relayDir, cacheRoot: options.cacheRoot, - referencePath: joinRemotePath(host, options.relayDir, RUNTIME_REFERENCE_NAME), signal, exec, remote }) executable = pinned.executable - upload = pinned.upload + identityNode ??= pinned.executable + runtimeRef = { + path: joinRemotePath( + host, + options.relayDir, + `${RUNTIME_REF_NODE_PREFIX}${pinned.runtimeSha256}` + ), + sha256: pinned.runtimeSha256 + } } if (!executable) { throw new Error('The host did not identify its SQLite executable.') @@ -179,12 +182,13 @@ async function install( const relativePool = `${RELAY_REMOTE_DIR}/${RELAY_UPLOAD_STAGE_POOL_NAME}` const poolDir = joinRemotePath(host, remoteHome, relativePool) const owner = createRelayInstallMarkerFileName() - await exec(recoverOneStaleRelayUploadStageCommand(host, poolDir)) + const identity = identityNode ? { node: identityNode } : undefined + await exec(recoverOneStaleRelayUploadStageCommand(host, poolDir, undefined, identity)) const stage = parseReservedRelayUploadStage( host, poolDir, owner, - await exec(reserveRelayUploadStageCommand(host, poolDir, owner)) + await exec(reserveRelayUploadStageCommand(host, poolDir, owner, identity)) ) const stageDir = stage.slotDir const namespace = createRelayUploadStageNamespace(`${relativePool}/${stage.slotName}`, owner) @@ -194,40 +198,6 @@ async function install( : undefined let cleanupAllowed = true try { - if (upload) { - const { localRuntime } = upload - const localStage = await mkdtemp(join(dirname(localRuntime), '.vault-upload-')) - try { - const binaryName = ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE - const localBinary = join(localStage, binaryName) - await link(localRuntime, localBinary).catch(() => copyFile(localRuntime, localBinary)) - signal.throwIfAborted() - await remote(() => - uploadRelayDirectory(conn, localStage, joinRemotePath(host, stageDir, 'payload'), host, { - signal, - sftpNamespace: mapping() - }) - ) - const promoted = parseOpenCodeRuntimeResult( - await exec( - promoteOpenCodeRuntimeCommand({ - host, - nodePath: options.nodePath, - stagedBinary: joinRemotePath(host, stageDir, 'payload', binaryName), - executable, - expectedHash: upload.expectedHash, - repairToken: token - }) - ) - ) - if (promoted.status !== 'ready' || !promoted.executable) { - throw new Error('The host did not verify the uploaded SQLite runtime.') - } - executable = promoted.executable - } finally { - await rm(localStage, { recursive: true, force: true }).catch(() => {}) - } - } const referenceName = RUNTIME_REFERENCE_NAME const stagedReference = joinRemotePath(host, stageDir, 'payload', referenceName) signal.throwIfAborted() @@ -244,7 +214,8 @@ async function install( nodePath: options.nodePath, stagedReference, reference: joinRemotePath(host, options.relayDir, referenceName), - token + token, + runtimeRef }) ) ) @@ -254,11 +225,13 @@ async function install( throw error } finally { if (cleanupAllowed && !signal.aborted) { - await exec(cleanupOwnedRelayUploadStageCommand(host, stage, owner)).catch((error) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error + await exec(cleanupOwnedRelayUploadStageCommand(host, stage, owner, identity)).catch( + (error) => { + if (isUnconfirmedSshCommandTermination(error)) { + throw error + } } - }) + ) } } } diff --git a/src/main/ssh/ssh-relay-pinned-node-install.test.ts b/src/main/ssh/ssh-relay-pinned-node-install.test.ts index c0a373fa740..2b1cee1a989 100644 --- a/src/main/ssh/ssh-relay-pinned-node-install.test.ts +++ b/src/main/ssh/ssh-relay-pinned-node-install.test.ts @@ -286,7 +286,8 @@ describe('pinned relay on a Windows host', () => { }) }) - it('self-tests on node.exe with no chmod step', async () => { + it('confirms the runtime under the store lock, then self-tests on node.exe with no chmod step', async () => { + vi.mocked(execCommand).mockResolvedValueOnce(`${REMOTE_NODE_RUNTIME_READY}\r\n`) vi.mocked(runPinnedRuntimeSelfTest).mockResolvedValueOnce({ verdict: 'passed', report: { @@ -299,7 +300,18 @@ describe('pinned relay on a Windows host', () => { } }) await expect(verifyPinnedRelayInstall(windowsContext)).resolves.toBeUndefined() - expect(execCommand).not.toHaveBeenCalled() + // Windows store GC collects too, so the held-runtime check runs there as well (design D5). + expect(withRuntimeStoreLock).toHaveBeenCalledWith( + conn, + windowsHost, + 'C:/Users/u/.orca-remote/runtimes', + expect.any(Function), + undefined + ) + expect(execCommand).toHaveBeenCalledOnce() + const [, command, options] = vi.mocked(execCommand).mock.calls[0] + expect(command).toMatch(/^powershell\.exe /) + expect(options).toMatchObject({ wrapCommand: false }) expect(runPinnedRuntimeSelfTest).toHaveBeenCalledWith( conn, windowsContext.remoteRelayDir, diff --git a/src/main/ssh/ssh-relay-pinned-node-install.ts b/src/main/ssh/ssh-relay-pinned-node-install.ts index 10016458da6..230fc1d6ce6 100644 --- a/src/main/ssh/ssh-relay-pinned-node-install.ts +++ b/src/main/ssh/ssh-relay-pinned-node-install.ts @@ -126,7 +126,11 @@ async function confirmPinnedRuntimeHeld( conn, host, remoteDirname(runtimeDir, host), - () => execCommand(conn, remoteNodeRuntimePresentCommand(host, runtimeDir), { signal }), + () => + execCommand(conn, remoteNodeRuntimePresentCommand(host, runtimeDir), { + signal, + wrapCommand: !isWindowsRemoteHost(host) + }), signal ) if (present.trim() !== REMOTE_NODE_RUNTIME_READY) { @@ -140,8 +144,8 @@ async function confirmPinnedRuntimeHeld( /** Runs after the payload is promoted and before `.install-complete`, so a refused dir never completes. */ export async function verifyPinnedRelayInstall(context: PinnedInstallContext): Promise { const { conn, host, remoteRelayDir, plan, signal } = context - // Why POSIX only: the store lock and store GC are POSIX-only for now; rung C has no managed runtime. - if (plan.kind === 'pinned-node' && !isWindowsRemoteHost(host)) { + // Rung C runs no store runtime, so it has nothing store GC can take. + if (plan.kind === 'pinned-node') { await confirmPinnedRuntimeHeld({ ...context, plan }) } const spawnHelpers = orcadNodePtyNativeArtifacts(plan.target).filter((artifact) => diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.ts b/src/main/ssh/ssh-relay-upload-stage-commands.ts index 9a36c4119bf..e363a574a57 100644 --- a/src/main/ssh/ssh-relay-upload-stage-commands.ts +++ b/src/main/ssh/ssh-relay-upload-stage-commands.ts @@ -14,9 +14,11 @@ import { cleanupWindowsRelayUploadStageCommand, promoteWindowsRelayUploadStageCommand, recoverWindowsRelayUploadStageCommand, - reserveWindowsRelayUploadStageCommand + reserveWindowsRelayUploadStageCommand, + type WindowsUploadStageIdentity } from './ssh-relay-upload-stage-windows-commands' +export type { WindowsUploadStageIdentity } from './ssh-relay-upload-stage-windows-commands' export { RELAY_UPLOAD_STAGE_POOL_NAME, RELAY_UPLOAD_STAGE_SLOT_COUNT, @@ -52,11 +54,12 @@ function slotPaths( export function reserveRelayUploadStageCommand( host: RemoteHostPlatform, poolDir: string, - owner: string + owner: string, + identity?: WindowsUploadStageIdentity ): string { assertOwner(owner) return isWindowsRemoteHost(host) - ? reserveWindowsRelayUploadStageCommand(poolDir, owner) + ? reserveWindowsRelayUploadStageCommand(poolDir, owner, identity) : reservePosixStageCommand(poolDir, owner) } @@ -82,11 +85,12 @@ export function promoteOwnedRelayUploadStageCommand( host: RemoteHostPlatform, stage: RelayUploadStageSlot, owner: string, - destinationDir: string + destinationDir: string, + identity?: WindowsUploadStageIdentity ): string { assertOwner(owner) return isWindowsRemoteHost(host) - ? promoteWindowsRelayUploadStageCommand(stage, owner, destinationDir) + ? promoteWindowsRelayUploadStageCommand(stage, owner, destinationDir, identity) : promotePosixStageCommand(stage, owner, destinationDir) } @@ -100,22 +104,24 @@ export function relayUploadStagePromotionConfirmed(owner: string, output: string export function cleanupOwnedRelayUploadStageCommand( host: RemoteHostPlatform, stage: RelayUploadStageSlot, - owner: string + owner: string, + identity?: WindowsUploadStageIdentity ): string { assertOwner(owner) return isWindowsRemoteHost(host) - ? cleanupWindowsRelayUploadStageCommand(stage, owner) + ? cleanupWindowsRelayUploadStageCommand(stage, owner, identity) : cleanupPosixStageCommand(stage, owner) } export function recoverOneStaleRelayUploadStageCommand( host: RemoteHostPlatform, poolDir: string, - staleSeconds = RELAY_UPLOAD_STAGE_STALE_SECONDS + staleSeconds = RELAY_UPLOAD_STAGE_STALE_SECONDS, + identity?: WindowsUploadStageIdentity ): string { const cutoffSeconds = Math.max(1, Math.ceil(staleSeconds)) return isWindowsRemoteHost(host) - ? recoverWindowsRelayUploadStageCommand(poolDir, cutoffSeconds) + ? recoverWindowsRelayUploadStageCommand(poolDir, cutoffSeconds, identity) : recoverPosixStageCommand(poolDir, Math.ceil(cutoffSeconds / 60)) } diff --git a/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts b/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts index 18b490d6742..811b29de5f4 100644 --- a/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts +++ b/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts @@ -1,4 +1,4 @@ -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' import { RELAY_UPLOAD_IDENTITY_FILE_NAME, RELAY_UPLOAD_OWNER_FILE_NAME, @@ -9,11 +9,15 @@ import { type RelayUploadStageSlot } from './ssh-relay-upload-stage-contract' -export function reserveWindowsRelayUploadStageCommand(poolDir: string, owner: string): string { +export function reserveWindowsRelayUploadStageCommand( + poolDir: string, + owner: string, + identity: WindowsUploadStageIdentity = {} +): string { return powerShellCommand( [ "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(), + ...windowsFileIdentityScript(identity), `$pool = ${powerShellLiteral(poolDir)}`, `$owner = ${powerShellLiteral(owner)}`, '$null = New-Item -ItemType Directory -Force -Path $pool', @@ -43,8 +47,33 @@ export function reserveWindowsRelayUploadStageCommand(poolDir: string, owner: st ) } -function windowsFileIdentityScript(): string[] { +/** Where a Windows stage command reads file identities; `node` is a verified pinned node.exe. */ +export type WindowsUploadStageIdentity = { node?: string } + +/** + * `vol:indexHigh:indexLow` in lowercase hex, the format the legacy Add-Type helper persisted. + * libuv fills `dev`/`ino` from the same volume serial and file index, so either reader accepts + * the other's identity files. Single quotes only: PS 5.1 drops embedded double quotes from argv. + */ +export const WINDOWS_UPLOAD_STAGE_IDENTITY_JS = + "const s=require('fs').lstatSync(process.argv[1],{bigint:true});const m=0xffffffffn;" + + "process.stdout.write((s.dev&m).toString(16)+':'+(s.ino>>32n).toString(16)+':'+(s.ino&m).toString(16))" + +function windowsFileIdentityScript(identity: WindowsUploadStageIdentity = {}): string[] { + // Why normalize: a leading zero or upper-case digit from either reader is not a different file. + const normalize = + "function ConvertTo-OrcaFileIdentity([string]$value) { (($value.Trim().ToLowerInvariant() -split ':') | ForEach-Object { $digits = $_.TrimStart('0'); if ($digits -eq '') { '0' } else { $digits } }) -join ':' }" + if (identity.node) { + // Why node.exe and not Add-Type: runtime-compiled P/Invoke is an EDR signal (design D5). + return [ + normalize, + `$orcaIdentityNode = ${powerShellLiteral(identity.node)}`, + `$getFileIdentity = { param($path) $value = & $orcaIdentityNode -e ${powerShellNativeArg(WINDOWS_UPLOAD_STAGE_IDENTITY_JS)} -- $path; if ($LASTEXITCODE -ne 0) { throw 'Orca could not read a relay upload stage file identity' }; ConvertTo-OrcaFileIdentity ([string]$value) }` + ] + } + // Legacy: host-Node relays have no verified node.exe to run; see windows-edr-posture.md. return [ + normalize, "if ($env:OS -eq 'Windows_NT') {", "if ($null -eq ('OrcaRelayUploadFileIdentity' -as [type])) {", "Add-Type -TypeDefinition @'", @@ -66,9 +95,9 @@ function windowsFileIdentityScript(): string[] { '}', "'@", '}', - '$getFileIdentity = { param($path) [OrcaRelayUploadFileIdentity]::Read($path) }', + '$getFileIdentity = { param($path) ConvertTo-OrcaFileIdentity ([OrcaRelayUploadFileIdentity]::Read($path)) }', '} else {', - '$getFileIdentity = { param($path) $resolved = (Get-Item -LiteralPath $path -Force -ErrorAction Stop).FullName; $value = & /usr/bin/stat -f "%i" -- $resolved 2>$null; if ($LASTEXITCODE -ne 0) { $value = & /usr/bin/stat -c "%i" -- $resolved }; ([string]$value).Trim() }', + '$getFileIdentity = { param($path) $resolved = (Get-Item -LiteralPath $path -Force -ErrorAction Stop).FullName; $value = & /usr/bin/stat -f "%i" -- $resolved 2>$null; if ($LASTEXITCODE -ne 0) { $value = & /usr/bin/stat -c "%i" -- $resolved }; ConvertTo-OrcaFileIdentity ([string]$value) }', '}' ] } @@ -103,7 +132,7 @@ function windowsOwnershipScript( '$ownerItem = Get-Item -LiteralPath $ownerPath -Force -ErrorAction SilentlyContinue', '$identityItem = Get-Item -LiteralPath $identityPath -Force -ErrorAction SilentlyContinue', '$actualOwner = if ($null -ne $ownerItem) { [System.IO.File]::ReadAllText($ownerPath).Trim() } else { "" }', - '$expectedIdentity = if ($null -ne $identityItem) { [System.IO.File]::ReadAllText($identityPath).Trim() } else { "" }', + '$expectedIdentity = if ($null -ne $identityItem) { ConvertTo-OrcaFileIdentity ([System.IO.File]::ReadAllText($identityPath)) } else { "" }', '$actualIdentity = if ($null -ne $claimItem) { & $getFileIdentity $ownedPath } else { "" }', '$owned = ($null -ne $claimItem) -and $claimItem.PSIsContainer -and (($claimItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $ownerItem) -and -not $ownerItem.PSIsContainer -and (($ownerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $identityItem) -and -not $identityItem.PSIsContainer -and (($identityItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($actualOwner -ceq $expectedOwner) -and ($actualIdentity -ceq $expectedIdentity)', ...(requirePayload @@ -133,7 +162,7 @@ function windowsStaleOwnershipScript(pathExpression: string): string[] { '$ownerItem = Get-Item -LiteralPath $ownerPath -Force -ErrorAction SilentlyContinue', '$identityItem = Get-Item -LiteralPath $identityPath -Force -ErrorAction SilentlyContinue', '$actualOwner = if ($null -ne $ownerItem) { [System.IO.File]::ReadAllText($ownerPath).Trim() } else { "" }', - '$expectedIdentity = if ($null -ne $identityItem) { [System.IO.File]::ReadAllText($identityPath).Trim() } else { "" }', + '$expectedIdentity = if ($null -ne $identityItem) { ConvertTo-OrcaFileIdentity ([System.IO.File]::ReadAllText($identityPath)) } else { "" }', '$actualIdentity = if ($null -ne $ownedItem) { & $getFileIdentity $ownedPath } else { "" }', "$owned = ($null -ne $ownedItem) -and $ownedItem.PSIsContainer -and (($ownedItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $ownerItem) -and -not $ownerItem.PSIsContainer -and (($ownerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $identityItem) -and -not $identityItem.PSIsContainer -and (($identityItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($actualIdentity -ceq $expectedIdentity) -and ($ownerItem.LastWriteTimeUtc -lt $cutoff) -and ($actualOwner -match '^\\.sftp-namespace-[0-9a-f]{32}$')", '$reparse = $null', @@ -161,12 +190,13 @@ function windowsDeleteOwnedClaimScript(owner: string): string[] { export function promoteWindowsRelayUploadStageCommand( stage: RelayUploadStageSlot, owner: string, - destinationDir: string + destinationDir: string, + identity: WindowsUploadStageIdentity = {} ): string { return powerShellCommand( [ "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(), + ...windowsFileIdentityScript(identity), ...windowsClaimPrelude(stage), ...windowsOwnershipScript(owner, true), 'if (-not $owned) {', @@ -183,12 +213,13 @@ export function promoteWindowsRelayUploadStageCommand( export function cleanupWindowsRelayUploadStageCommand( stage: RelayUploadStageSlot, - owner: string + owner: string, + identity: WindowsUploadStageIdentity = {} ): string { return powerShellCommand( [ "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(), + ...windowsFileIdentityScript(identity), ...windowsClaimPrelude(stage), ...windowsOwnershipScript(owner, true), 'if ($owned) {', @@ -202,12 +233,13 @@ export function cleanupWindowsRelayUploadStageCommand( export function recoverWindowsRelayUploadStageCommand( poolDir: string, - staleSeconds: number + staleSeconds: number, + identity: WindowsUploadStageIdentity = {} ): string { return powerShellCommand( [ "$ErrorActionPreference = 'Stop'", - ...windowsFileIdentityScript(), + ...windowsFileIdentityScript(identity), `$pool = ${powerShellLiteral(poolDir)}`, `$cutoff = [DateTime]::UtcNow.AddSeconds(-${staleSeconds})`, '$poolItem = Get-Item -LiteralPath $pool -Force -ErrorAction SilentlyContinue', diff --git a/src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts b/src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts new file mode 100644 index 00000000000..a61081daad3 --- /dev/null +++ b/src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts @@ -0,0 +1,201 @@ +/** + * Upload-stage file identity on Windows through a verified pinned node.exe instead of the legacy + * Add-Type helper (design D5, docs/reference/windows-edr-posture.md). + */ +import { spawnSync } from 'node:child_process' +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' +import { + cleanupOwnedRelayUploadStageCommand, + parseReservedRelayUploadStage, + promoteOwnedRelayUploadStageCommand, + recoverOneStaleRelayUploadStageCommand, + relayUploadStagePromotionConfirmed, + reserveRelayUploadStageCommand, + type WindowsUploadStageIdentity +} from './ssh-relay-upload-stage-commands' +import { WINDOWS_UPLOAD_STAGE_IDENTITY_JS } from './ssh-relay-upload-stage-windows-commands' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' + +const windows = getRemoteHostPlatform('win32-x64') +const owner = '.sftp-namespace-123e4567e89b12d3a456426614174000' +const pool = 'C:/Users/ada/.orca-remote/.upload-stages' +const pinned: WindowsUploadStageIdentity = { + node: 'C:/Users/ada/.orca-remote/runtimes/node-abc/node.exe' +} +const stage = parseReservedRelayUploadStage( + windows, + pool, + owner, + `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-3` +) +const roots: string[] = [] + +function allCommands(identity?: WindowsUploadStageIdentity): string[] { + return [ + reserveRelayUploadStageCommand(windows, pool, owner, identity), + promoteOwnedRelayUploadStageCommand(windows, stage, owner, `${pool}/../relay-x`, identity), + cleanupOwnedRelayUploadStageCommand(windows, stage, owner, identity), + recoverOneStaleRelayUploadStageCommand(windows, pool, undefined, identity) + ].map(decodeRemotePowerShellScript) +} + +function tempDir(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-stage-identity-')) + roots.push(root) + return root +} + +function nodeIdentity(path: string): string { + const result = spawnSync(process.execPath, ['-e', WINDOWS_UPLOAD_STAGE_IDENTITY_JS, '--', path], { + encoding: 'utf8' + }) + expect(result.status, result.stderr).toBe(0) + return result.stdout +} + +afterEach(() => { + for (const root of roots.splice(0)) { + removeTreeSync(root) + } +}) + +describe('Windows upload-stage identity commands', () => { + it('compile nothing once a verified node.exe is known', () => { + for (const script of allCommands(pinned)) { + expect(script).not.toMatch(/Add-Type|DllImport|ExecutionPolicy|\.ps1/) + expect(script).toContain(`$orcaIdentityNode = '${pinned.node}'`) + } + }) + + it('keep the Add-Type helper only for relays with no verified node.exe', () => { + for (const script of allCommands()) { + expect(script).toContain('Add-Type -TypeDefinition') + expect(script).not.toContain('$orcaIdentityNode') + } + }) + + it('run node.exe with the fixed script and the path as an argument', () => { + const script = allCommands(pinned)[0] + const lines = script.split('\n').filter((line) => line.includes('OrcaFileIdentity')) + expect(lines).toMatchInlineSnapshot(` + [ + "function ConvertTo-OrcaFileIdentity([string]$value) { (($value.Trim().ToLowerInvariant() -split ':') | ForEach-Object { $digits = $_.TrimStart('0'); if ($digits -eq '') { '0' } else { $digits } }) -join ':' }", + "$getFileIdentity = { param($path) $value = & $orcaIdentityNode -e 'const s=require(''fs'').lstatSync(process.argv[1],{bigint:true});const m=0xffffffffn;process.stdout.write((s.dev&m).toString(16)+'':''+(s.ino>>32n).toString(16)+'':''+(s.ino&m).toString(16))' -- $path; if ($LASTEXITCODE -ne 0) { throw 'Orca could not read a relay upload stage file identity' }; ConvertTo-OrcaFileIdentity ([string]$value) }", + ] + `) + }) +}) + +describe('the pinned node.exe identity script', () => { + it("prints the legacy helper's vol:indexHigh:indexLow lowercase hex", () => { + const dir = tempDir() + const stats = lstatSync(dir, { bigint: true }) + const mask = 0xffffffffn + expect(nodeIdentity(dir)).toBe( + `${(stats.dev & mask).toString(16)}:${(stats.ino >> 32n).toString(16)}:${(stats.ino & mask).toString(16)}` + ) + expect(nodeIdentity(dir)).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/) + }) + + it('survives the claim rename but not a copy with the same contents', () => { + const root = tempDir() + const slot = join(root, 'slot-0') + mkdirSync(slot) + const before = nodeIdentity(slot) + renameSync(slot, join(root, 'claim-0')) + expect(nodeIdentity(join(root, 'claim-0'))).toBe(before) + cpSync(join(root, 'claim-0'), join(root, 'copy'), { recursive: true }) + expect(nodeIdentity(join(root, 'copy'))).not.toBe(before) + }) +}) + +const powerShell = [ + process.env.ORCA_POWERSHELL_EXECUTABLE, + ...(process.platform === 'win32' ? ['powershell.exe', 'pwsh.exe'] : ['pwsh']) +].find( + (candidate) => + candidate && + spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { + stdio: 'ignore' + }).status === 0 +) + +function runPowerShell(command: string): { status: number | null; stdout: string } { + const result = spawnSync( + powerShell!, + ['-NoProfile', '-NonInteractive', '-Command', decodeRemotePowerShellScript(command)], + { encoding: 'utf8' } + ) + expect(result.status, result.stderr).toBe(0) + return result +} + +function reserve(localPool: string, identity?: WindowsUploadStageIdentity): string { + const out = runPowerShell(reserveRelayUploadStageCommand(windows, localPool, owner, identity)) + const reserved = parseReservedRelayUploadStage(windows, localPool, owner, out.stdout) + writeFileSync(join(reserved.slotDir, 'payload', 'relay.js'), 'relay') + return reserved.slotName +} + +function promote(localPool: string, slotName: string, identity?: WindowsUploadStageIdentity) { + const destination = join(localPool, '..', 'relay-x') + mkdirSync(destination, { recursive: true }) + const reserved = parseReservedRelayUploadStage( + windows, + localPool, + owner, + `__ORCA_UPLOAD_STAGE_SLOT__${owner}:${slotName}` + ) + const out = runPowerShell( + promoteOwnedRelayUploadStageCommand(windows, reserved, owner, destination, identity) + ) + return { + promoted: relayUploadStagePromotionConfirmed(owner, out.stdout), + copied: existsSync(join(destination, 'relay.js')) + } +} + +describe.runIf(powerShell)('Windows upload-stage identity (real PowerShell)', () => { + const node = { node: process.execPath } + + it('reserves and promotes through node.exe alone', { timeout: 120_000 }, () => { + const localPool = join(tempDir(), 'pool') + const slot = reserve(localPool, node) + expect(promote(localPool, slot, node)).toEqual({ promoted: true, copied: true }) + }) + + it('accepts an identity file in any hex spelling of the same file', { timeout: 120_000 }, () => { + const localPool = join(tempDir(), 'pool') + const slot = reserve(localPool, node) + const identityFile = join(localPool, slot, '.orca-upload-identity') + const [vol, high, low] = readFileSync(identityFile, 'utf8').split(':') + writeFileSync(identityFile, `${vol.toUpperCase()}:000${high}:${low.toUpperCase()}\r\n`) + expect(promote(localPool, slot, node)).toEqual({ promoted: true, copied: true }) + }) + + // Why Windows only: off Windows the legacy branch reads `stat %i`, a different format. + it.runIf(process.platform === 'win32')( + 'reads identity files the legacy Add-Type helper wrote, and the reverse', + { timeout: 240_000 }, + () => { + const oldToNew = join(tempDir(), 'pool') + expect(promote(oldToNew, reserve(oldToNew), node)).toEqual({ promoted: true, copied: true }) + const newToOld = join(tempDir(), 'pool') + expect(promote(newToOld, reserve(newToOld, node))).toEqual({ promoted: true, copied: true }) + } + ) +}) diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index daa8715e260..4cf2b53d96e 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -1,5 +1,6 @@ import { RELAY_INSTALL_COMPLETE_FILENAME, + RELAY_PID_FILENAME, relayArtifactFilenames } from '../../shared/relay-artifacts' import { @@ -206,6 +207,51 @@ export type WindowsRelayLivenessOptions = { pipePaths: string[] } +/** + * Design D5 on Windows: a recorded `.relay-pid` that is still running answers ALIVE before any + * pipe is touched (a connect would cancel an idling daemon's grace timer). Only a dead PID + * (ESRCH) plus every pipe refusing is DEAD/WAITING; any other kill(0) error is UNVERIFIABLE. + * Without a PID file the old marker/pipe rule stands. + */ +export const WINDOWS_RELAY_LIVENESS_JS = [ + 'const fs=require("fs"),path=require("path"),net=require("net");', + 'const [dir,...seed]=process.argv.slice(1);', + 'const answer=(token)=>{process.stdout.write(token);process.exit(0)};', + 'let pidDead=false;', + 'let rawPid=null;', + `try{rawPid=fs.readFileSync(path.join(dir,${JSON.stringify(RELAY_PID_FILENAME)}),"utf8").trim()}catch(e){if(e.code!=="ENOENT")answer("UNVERIFIABLE")}`, + 'if(rawPid!==null){', + 'if(!/^[1-9][0-9]*$/.test(rawPid))answer("UNVERIFIABLE");', + 'try{process.kill(Number(rawPid),0)}catch(e){if(e.code!=="ESRCH")answer("UNVERIFIABLE");pidDead=true}', + 'if(!pidDead)answer("ALIVE")', + '}', + 'const valid=/^\\\\\\\\[.?]\\\\pipe\\\\orca-relay-[0-9a-f]{20}$/i;', + 'const pipes=[];', + 'let markerCount=0;', + 'for(const p of seed){if(valid.test(p)&&!pipes.includes(p))pipes.push(p)}', + 'try{for(const name of fs.readdirSync(dir)){', + 'if(!name.startsWith(".windows-active-pipe-"))continue;', + 'markerCount++;', + 'const p=fs.readFileSync(path.join(dir,name),"utf8").trim();', + 'if(valid.test(p)&&!pipes.includes(p))pipes.push(p)', + '}}catch{}', + 'if(markerCount===0&&pipes.length===0)answer(pidDead?"DEAD":"ALIVE");', + 'let i=0;', + 'function done(ok){process.stdout.write(ok?"ALIVE":"WAITING")}', + 'function next(){', + 'const pipe=pipes[i++];', + 'if(!pipe)return done(false);', + 'const s=net.connect(pipe);', + 'let settled=false;', + 'function finish(ok){if(settled)return;settled=true;s.destroy();if(ok)done(true);else next()}', + 's.setTimeout(200);', + 's.on("connect",()=>finish(true));', + 's.on("timeout",()=>finish(false));', + 's.on("error",()=>finish(false));', + '}', + 'next();' +].join('') + export function relayLivenessProbeCommand( host: RemoteHostPlatform, dir: string, @@ -221,35 +267,7 @@ export function relayLivenessProbeCommand( if (!windowsOptions) { return powerShellCommand("'ALIVE'") } - const js = [ - 'const fs=require("fs"),path=require("path"),net=require("net");', - 'const [dir,...seed]=process.argv.slice(1);', - 'const valid=/^\\\\\\\\[.?]\\\\pipe\\\\orca-relay-[0-9a-f]{20}$/i;', - 'const pipes=[];', - 'let markerCount=0;', - 'for(const p of seed){if(valid.test(p)&&!pipes.includes(p))pipes.push(p)}', - 'try{for(const name of fs.readdirSync(dir)){', - 'if(!name.startsWith(".windows-active-pipe-"))continue;', - 'markerCount++;', - 'const p=fs.readFileSync(path.join(dir,name),"utf8").trim();', - 'if(valid.test(p)&&!pipes.includes(p))pipes.push(p)', - '}}catch{}', - 'if(markerCount===0&&pipes.length===0){process.stdout.write("ALIVE");process.exit(0)}', - 'let i=0;', - 'function done(ok){process.stdout.write(ok?"ALIVE":"WAITING")}', - 'function next(){', - 'const pipe=pipes[i++];', - 'if(!pipe)return done(false);', - 'const s=net.connect(pipe);', - 'let settled=false;', - 'function finish(ok){if(settled)return;settled=true;s.destroy();if(ok)done(true);else next()}', - 's.setTimeout(200);', - 's.on("connect",()=>finish(true));', - 's.on("timeout",()=>finish(false));', - 's.on("error",()=>finish(false));', - '}', - 'next();' - ].join('') + const js = WINDOWS_RELAY_LIVENESS_JS return commandWithNodePath( host, windowsOptions.nodePath,