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-<sha> 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-<sha> 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 <m4air@m4airs-Air.localdomain>
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-01 03:25:42 -07:00
committed by GitHub
co-authored by m4air m4air
parent dfdcfcf61f
commit 14d4bb2e2a
26 changed files with 1196 additions and 519 deletions
+2
View File
@@ -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
+3 -1
View File
@@ -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 = [
+39
View File
@@ -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 <fixed script> -- <path>`, 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
@@ -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,
@@ -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`
+30 -32
View File
@@ -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<string> =>
execCommand(conn, command, { signal, timeoutMs, wrapCommand: !windows })
const promote = (): Promise<string> => {
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) {
@@ -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')
})
})
@@ -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()
})
})
+34 -15
View File
@@ -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<string> {
return execCommand(conn, command, { wrapCommand: true, signal })
function exec(
conn: SshConnection,
host: RemoteHostPlatform,
command: string,
signal?: AbortSignal
): Promise<string> {
// 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<RuntimeStoreInventory | null> {
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<RuntimeStoreGcResult> {
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<RuntimeStoreGcResult> {
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<boolean> {
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<void> {
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<boolean> {
try {
await exec(conn, removeRemoteTreeCommand(host, path), signal)
await exec(conn, host, removeRemoteTreeCommand(host, path), signal)
return true
} catch (err) {
if (isUnconfirmedSshCommandTermination(err)) {
@@ -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)
}
+11 -4
View File
@@ -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<void> {
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<boolean> {
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) {
@@ -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<typeof DeployHelpers>()),
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'])
})
})
@@ -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')
)
}
+33 -6
View File
@@ -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
})
@@ -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<typeof NodeRuntimeStore>()),
ensureRemoteOrcadNodeRuntime: mocks.ensure
}))
vi.mock('./ssh-relay-opencode-runtime-commands', async (original) => ({
...(await original<typeof RuntimeCommands>()),
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<string>) {
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()
})
})
+20 -68
View File
@@ -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<string, Promise<string>>()
/** 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<string>
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<string> {
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)
@@ -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', () => {
@@ -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] : [])
]
)
}
@@ -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}'`)
}
})
})
+27 -54
View File
@@ -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
}
}
})
)
}
}
}
@@ -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,
@@ -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<void> {
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) =>
@@ -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))
}
@@ -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',
@@ -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 })
}
)
})
+47 -29
View File
@@ -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,