mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}'`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user