Files
orca/src/main/ssh/remote-node-runtime-store-inventory.ts
T
14d4bb2e2a 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>
2026-10-01 03:25:42 -07:00

144 lines
6.0 KiB
TypeScript

/**
* One read-only pass over `~/.orca-remote/` answering what the runtime store GC needs: store
* entries, which runtimes are referenced, verified order, and which a running process holds.
*/
import {
ORCAD_NODE_RUNTIME_DIR_PREFIX,
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
ORCAD_RUNTIMES_DIRNAME
} from '../../shared/orcad-artifacts'
import { shellEscape } from './ssh-connection-utils'
import { RELAY_REMOTE_DIR } from './relay-protocol'
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-'
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})$`
)
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})[./\\\\]`,
'i'
)
export type RuntimeStoreInventory = {
/** Store entry names: `node-<sha>` and this GC's tombstones. */
entries: string[]
/** `node-<sha>` names with `.verified`, newest first. */
verifiedNewestFirst: string[]
referenced: Set<string>
held: Set<string>
/** False when neither `ps` nor `/proc` answered; nothing may then be called idle. */
processCheckRan: boolean
/** Other `~/.orca-remote/` directory names, for legacy diagnostics. */
dirNames: string[]
}
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)}`,
`rt="$root"/${ORCAD_RUNTIMES_DIRNAME}`,
`[ -d "$rt" ] || { printf '%s\\n' ${INVENTORY_OK}; exit 0; }`,
`[ -r "$root" ] && [ -x "$root" ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`,
'n=0',
// Why every sibling and not a prefix: a directory this client does not recognise may still
// be a newer Orca's install that names a runtime.
'for d in "$root"/* "$root"/.[!.]*; do',
' [ -d "$d" ] || continue',
' name=${d##*/}',
` [ "$name" = ${ORCAD_RUNTIMES_DIRNAME} ] && continue`,
` [ -r "$d" ] && [ -x "$d" ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`,
' n=$((n+1))',
` [ "$n" -le ${MAX_DIRS} ] || { printf '%s\\n' ${REFS_ERR}; exit 0; }`,
` printf 'DIR %s\\n' "$name"`,
` if [ -e "$d"/${ORCAD_NODE_RUNTIME_MARKER_FILENAME} ]; then`,
` sha=$(cat "$d"/${ORCAD_NODE_RUNTIME_MARKER_FILENAME}) || { printf '%s\\n' ${REFS_ERR}; exit 0; }`,
` printf 'REF %s\\n' "$sha"`,
' fi',
` for f in "$d"/${refPrefix}*; do`,
' [ -e "$f" ] || continue',
` printf 'REF %s\\n' "\${f##*/${refPrefix}}"`,
' done',
'done',
`for e in "$rt"/${ORCAD_NODE_RUNTIME_DIR_PREFIX}* "$rt"/${RUNTIME_STORE_TOMBSTONE_PREFIX}${ORCAD_NODE_RUNTIME_DIR_PREFIX}*; do`,
` [ -d "$e" ] && printf 'ENTRY %s\\n' "\${e##*/}"`,
'done',
`set -- "$rt"/${ORCAD_NODE_RUNTIME_DIR_PREFIX}*/.verified`,
'if [ -e "$1" ]; then',
` order=$(ls -1t -- "$@") || { printf '%s\\n' ${REFS_ERR}; exit 0; }`,
` printf '%s\\n' "$order" | while IFS= read -r v; do v=\${v%/.verified}; printf 'VERIFIED %s\\n' "\${v##*/}"; done`,
'fi',
// Process checks only add holds, so an unmatched `grep` is not an error. Why not "$rt/":
// /proc exe and argv may name the store through another spelling of a symlinked home.
'if ps_out=$(ps -e -o args= 2>/dev/null); then',
" printf 'PROCESS_CHECK ps\\n'",
` printf '%s\\n' "$ps_out" | grep -F -- /${ORCAD_RUNTIMES_DIRNAME}/ | sed 's/^/HOLD /'`,
'fi',
'if [ -n "$(readlink /proc/self/exe 2>/dev/null)" ]; then',
" printf 'PROCESS_CHECK proc\\n'",
' for p in /proc/[0-9]*/exe; do',
' t=$(readlink "$p" 2>/dev/null) || continue',
` case "$t" in */${ORCAD_RUNTIMES_DIRNAME}/*) printf 'HOLD %s\\n' "$t";; esac`,
' done',
'fi',
`printf '%s\\n' ${INVENTORY_OK}`
].join('\n')
}
/** Null when the host could not produce a complete inventory; that keeps every runtime. */
export function parseRuntimeStoreInventory(output: string): RuntimeStoreInventory | null {
const lines = output.split(/\r?\n/).map((line) => line.trim())
if (!lines.includes(INVENTORY_OK) || lines.includes(REFS_ERR)) {
return null
}
const inventory: RuntimeStoreInventory = {
entries: [],
verifiedNewestFirst: [],
referenced: new Set(),
held: new Set(),
processCheckRan: false,
dirNames: []
}
for (const line of lines) {
const space = line.indexOf(' ')
const tag = space === -1 ? line : line.slice(0, space)
const value = space === -1 ? '' : line.slice(space + 1).trim()
if (tag === 'REF') {
// An unattributable reference could name any runtime, so it stops the pass.
if (!SHA256.test(value)) {
return null
}
inventory.referenced.add(value)
} else if (
tag === 'ENTRY' &&
(RUNTIME_STORE_ENTRY_NAME.test(value) || RUNTIME_STORE_TOMBSTONE_NAME.test(value))
) {
inventory.entries.push(value)
} 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]?.toLowerCase()
if (sha) {
inventory.held.add(sha)
}
} else if (tag === 'PROCESS_CHECK') {
inventory.processCheckRan = true
} else if (tag === 'DIR' && value) {
inventory.dirNames.push(value)
}
}
return inventory
}