diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index fc427e28940b..b495032ae554 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -30,6 +30,7 @@ on: - 'config/scripts/relay-windows-process-tree-staging.mjs' - 'config/scripts/windows-process-tree-gyp-rebuild.mjs' - 'src/shared/relay-windows-breakaway-launch.ts' + - 'src/shared/windows-breakaway-launch*.ts' - '!src/**/*.test.ts' - 'src/main/ssh/ssh-relay-windows-host-lane.test.ts' - 'config/ci/windows-ssh-provider/**' diff --git a/docs/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md index d1845ec5cbef..39bb20388e84 100644 --- a/docs/reference/windows-edr-posture.md +++ b/docs/reference/windows-edr-posture.md @@ -396,6 +396,16 @@ built without the addon or a job that refuses breakaway, and a refusal there is reported as `ORCA_RELAY_LAUNCH_REFUSED`. The Windows SSH-host lanes run with no WMI grant and assert the breakaway route. +`orcad.js` exposes the same launcher (`src/shared/windows-breakaway-launcher.ts`) +with **no** WMI fallback: a host that cannot break away refuses the orcad launch. +Windows orcad operations start **no PowerShell**: sshd's DefaultShell runs the +pinned `node.exe` directly with plain path arguments, against one content-addressed +host script staged beside the slots (`src/main/ssh/orcad-windows-host-script.ts`). +Only a `node.exe` path that itself needs quoting (a profile name with a space) +falls back to one unencoded `powershell.exe -Command`. The launch waits for +readiness host-side, one exec per 20 s at most, rather than re-running an exec +every 500 ms. + ## Signing is not the gate The most useful calibration in the whole incident set came from the reporter's diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index 4f1f69763b95..d6744cdcb10b 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -88,4 +88,32 @@ describe('orcad entry', () => { expect(order).toEqual([`managed-stop:${flag} {}`]) } ) + + it('runs the Windows breakaway launcher without preflights or a runtime', async () => { + vi.spyOn(process, 'argv', 'get').mockReturnValue([ + 'runtime', + 'orcad.js', + '--windows-breakaway-launch', + '--stdout-file', + 'out', + '--stderr-file', + 'err', + '--orcad-args', + '--json' + ]) + const write = vi + .spyOn(process.stdout, 'write') + .mockImplementation((_chunk: unknown, callback?: unknown) => { + if (typeof callback === 'function') { + callback() + } + return true + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub only records the call; nothing reads its never return. + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + await import('./main') + await vi.waitFor(() => expect(exit).toHaveBeenCalled()) + expect(String(write.mock.calls[0]?.[0])).toMatch(/^ORCA_ORCAD_LAUNCH /u) + expect(order).toEqual([]) + }) }) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 211fc94540d3..b89b8f6acd9c 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -12,6 +12,11 @@ import { ORCAD_CANCEL_MANAGED_STOP_FLAG, ORCAD_COMPLETE_MANAGED_STOP_FLAG } from '../../shared/orcad-stop-request' +import { + ORCAD_WINDOWS_BREAKAWAY_CONTRACT, + WINDOWS_BREAKAWAY_LAUNCH_FLAG +} from '../../shared/windows-breakaway-launch' +import { runWindowsBreakawayLaunchIfRequested } from '../../shared/windows-breakaway-launcher' // Why exit before the preflight: reaching this line means the whole module graph resolved // under plain Node, which is all the build guard needs to prove. Probing natives or @@ -34,8 +39,10 @@ function failStartup(error: unknown): void { process.exit(resolveOrcadExitCode(error)) } -// Why before the bundled handoff and preflights: completing a stop must not start a runtime. -if ( +// Why before the bundled handoff and preflights: launching or completing a stop must not start a runtime. +if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { + runWindowsBreakawayLaunchIfRequested(ORCAD_WINDOWS_BREAKAWAY_CONTRACT, process.argv) +} else if ( process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG || process.argv[2] === ORCAD_CANCEL_MANAGED_STOP_FLAG ) { diff --git a/src/main/orcad/orcad-managed-stop-command.ts b/src/main/orcad/orcad-managed-stop-command.ts index b62fa2b2311c..af11040ed880 100644 --- a/src/main/orcad/orcad-managed-stop-command.ts +++ b/src/main/orcad/orcad-managed-stop-command.ts @@ -1,6 +1,7 @@ /** * `orcad --complete-managed-stop `: one stop, one JSON verdict line on stdout. * `orcad --cancel-managed-stop `: one cancellation, one JSON outcome line. + * Either takes `--request-file ` in place of the JSON. * * Exit 0 means a result was printed — read it, the exit code does not carry it. 64 is a * malformed invocation; 1 is a failure before any result, which is never evidence of exit. @@ -8,6 +9,7 @@ import { ORCAD_CANCEL_MANAGED_STOP_FLAG, ORCAD_COMPLETE_MANAGED_STOP_FLAG, + ORCAD_MANAGED_STOP_REQUEST_FILE_FLAG, OrcadManagedStopRequestSchema, type OrcadManagedStopCancellation, type OrcadManagedStopCompletion, @@ -15,6 +17,7 @@ import { } from '../../shared/orcad-stop-request' import { cancelOrcadManagedStop } from './orcad-managed-stop-cancellation' import { readOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt' +import { readOrcadManagedStopRequest } from './orcad-managed-stop-request' import { ZodError } from 'zod' import { completeOrcadManagedStop, @@ -56,7 +59,16 @@ export function runOrcadManagedStopCancelCommand( } function parseRequestArgument(argv: readonly string[], flag: string): OrcadManagedStopRequest { - if (argv.length !== 2 || argv[0] !== flag || !argv[1]) { + if (argv[0] !== flag) { + throw new Error('orcad_managed_stop_invalid_arguments') + } + if (argv[1] === ORCAD_MANAGED_STOP_REQUEST_FILE_FLAG) { + if (argv.length !== 3 || !argv[2]) { + throw new Error('orcad_managed_stop_invalid_arguments') + } + return readOrcadManagedStopRequest(argv[2]) + } + if (argv.length !== 2 || !argv[1]) { throw new Error('orcad_managed_stop_invalid_arguments') } return OrcadManagedStopRequestSchema.parse(JSON.parse(argv[1])) diff --git a/src/main/orcad/orcad-managed-stop.test.ts b/src/main/orcad/orcad-managed-stop.test.ts index c6f19bcf0e81..a9b6f848cc0f 100644 --- a/src/main/orcad/orcad-managed-stop.test.ts +++ b/src/main/orcad/orcad-managed-stop.test.ts @@ -178,6 +178,24 @@ describe('completing a managed stop', () => { 'invalid_arguments' ) }) + + it('reads the request from a staged file instead of argv', async () => { + const { lock, request } = running() + const staged = join(lock.path, '..', 'staged-request.json') + writeFileSync(staged, JSON.stringify(request)) + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const completion = await runOrcadManagedStopCommand( + ['--complete-managed-stop', '--request-file', staged], + options({ probeProcess: () => 'missing' }) + ) + expect(completion).toMatchObject({ + transactionId: request.transactionId, + verdict: 'exited' + }) + await expect( + runOrcadManagedStopCommand(['--complete-managed-stop', '--request-file']) + ).rejects.toThrow('invalid_arguments') + }) }) describe('managed stops that retire the daemon', () => { diff --git a/src/main/ssh/orcad-activation-host-test-harness.ts b/src/main/ssh/orcad-activation-host-test-harness.ts index 2ce1353529d2..bc75d0919e81 100644 --- a/src/main/ssh/orcad-activation-host-test-harness.ts +++ b/src/main/ssh/orcad-activation-host-test-harness.ts @@ -18,6 +18,11 @@ const MIGRATION = '|migrated-by-new' export type CrashMode = 'before' | 'after' +/** The one-shot readiness read, or the launch loop's host-side wait. */ +export function isReadinessRead(command: string): boolean { + return command.startsWith('head -c ') || command.includes('orcad_readiness_wait') +} + export class FakeOrcadHost { record: string | null = null journal: string | null = null @@ -168,7 +173,7 @@ export class FakeOrcadHost { return '9999' }) } - if (command.startsWith('head -c ') && version) { + if (isReadinessRead(command) && version) { return this.alive.has(version) ? readyLine(version) : '' } if (command.includes('echo PRESENT') && snapshot) { diff --git a/src/main/ssh/orcad-managed-remote-stop.ts b/src/main/ssh/orcad-managed-remote-stop.ts index 1200192d9dbc..9cedd78a8497 100644 --- a/src/main/ssh/orcad-managed-remote-stop.ts +++ b/src/main/ssh/orcad-managed-remote-stop.ts @@ -5,6 +5,7 @@ * payload (runtime ID, PID, capability) and the data root's instance lock (PID, start time, * nonce). The slot's own `orcad.js` then writes the request and proves exit on the host. */ +import { randomUUID } from 'node:crypto' import { shellEscape } from './ssh-connection-utils' import { assertPosixOrcadHost } from './orcad-remote-host-support' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' @@ -12,11 +13,20 @@ import { parseOrcadReadinessOutput, readOrcadReadinessCommand } from './orcad-re import { execOrcadRemote } from './orcad-remote-runtime-control' import { readBoundedOrcadRemoteRecord } from './orcad-remote-record-file' import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot' -import { joinRemotePath } from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' +import { + orcadWindowsBaseDir, + orcadWindowsHostOpCommand, + orcadWindowsNodeCommandLine, + readOrcadWindowsEncodedAnswer +} from './orcad-remote-windows-node' +import { ORCAD_WINDOWS_RUNTIME_MARKER } from './orcad-windows-host-script' import { ORCAD_LOCK_FILE_NAME, parseOrcadInstanceLockRecord } from '../orcad/orcad-instance-lock' import { ORCAD_CANCEL_MANAGED_STOP_FLAG, ORCAD_COMPLETE_MANAGED_STOP_FLAG, + ORCAD_MANAGED_STOP_REQUEST_FILE_FLAG, ORCAD_STOP_REQUESTS_CAPABILITY, OrcadManagedStopCancellationSchema, OrcadManagedStopCompletionSchema, @@ -104,11 +114,13 @@ async function runSlotCommand( ): Promise { const slotDir = orcadSlotDir(options, request.version) const entry = joinRemotePath(options.host, slotDir, 'orcad.js') - const output = await execOrcadRemote( - options, - `${selectOrcadSlotRuntimeCommand(options.host, slotDir, options.nodePath)} && ` + - `"$orcad_runtime" ${shellEscape(entry)} ${flag} ${shellEscape(JSON.stringify(request))}` - ) + const output = isWindowsRemoteHost(options.host) + ? await runWindowsSlotCommand(options, slotDir, entry, request, flag) + : await execOrcadRemote( + options, + `${selectOrcadSlotRuntimeCommand(options.host, slotDir, options.nodePath)} && ` + + `"$orcad_runtime" ${shellEscape(entry)} ${flag} ${shellEscape(JSON.stringify(request))}` + ) const line = output .trim() .split('\n') @@ -126,3 +138,52 @@ async function runSlotCommand( } return parsed } + +/** Windows passes the request as a staged file: JSON on a command line meets two quoting layers. */ +async function runWindowsSlotCommand( + options: OrcadSlotOptions, + slotDir: string, + entry: string, + request: OrcadManagedStopRequest, + flag: string +): Promise { + const baseDir = orcadWindowsBaseDir(options.host, slotDir) + const runtime = readOrcadWindowsEncodedAnswer( + await execOrcadRemote( + options, + orcadWindowsHostOpCommand(options.host, baseDir, 'slot-runtime', [slotDir]) + ), + ORCAD_WINDOWS_RUNTIME_MARKER + ) + if (!runtime) { + throw new Error('The Windows host did not name the runtime this orcad slot needs.') + } + const staged = joinRemotePath(options.host, slotDir, `.orcad-stop-command-${randomUUID()}.json`) + let removeStaged = true + try { + await options.conn.writeFile(staged, JSON.stringify(request), { + hostPlatform: options.host, + signal: options.signal + }) + return await execOrcadRemote( + options, + orcadWindowsNodeCommandLine(runtime, [ + entry, + flag, + ORCAD_MANAGED_STOP_REQUEST_FILE_FLAG, + staged + ]) + ) + } catch (error) { + // The command may still be reading it. + removeStaged = !isUnconfirmedSshCommandTermination(error) + throw error + } finally { + if (removeStaged) { + await execOrcadRemote( + options, + orcadWindowsHostOpCommand(options.host, baseDir, 'remove-file', [staged]) + ).catch(() => {}) + } + } +} diff --git a/src/main/ssh/orcad-remote-build-hash.ts b/src/main/ssh/orcad-remote-build-hash.ts index 7ed6384dfe78..1308c4868f04 100644 --- a/src/main/ssh/orcad-remote-build-hash.ts +++ b/src/main/ssh/orcad-remote-build-hash.ts @@ -1,10 +1,9 @@ /** The installed slot's `orcad.js` identity, the same 16-hex prefix orcad reports in its health. */ import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -const BUILD_HASH_MARKER = '__ORCAD_BUILD_HASH__' +import { orcadWindowsBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node' +import { ORCAD_BUILD_HASH_MARKER as BUILD_HASH_MARKER } from './orcad-windows-host-script' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' export async function readRemoteOrcadBuildHash( target: OrcadRemoteExecTarget, @@ -25,7 +24,14 @@ export function remoteOrcadBuildHashCommand( host: RemoteHostPlatform, remoteInstallDir: string ): string { - assertPosixOrcadHost(host) + if (isWindowsRemoteHost(host)) { + return orcadWindowsHostOpCommand( + host, + orcadWindowsBaseDir(host, remoteInstallDir), + 'build-hash', + [joinRemotePath(host, remoteInstallDir, 'orcad.js')] + ) + } const path = shellEscape(joinRemotePath(host, remoteInstallDir, 'orcad.js')) // Why both tools: GNU/busybox ship sha256sum, macOS ships shasum; either prints the digest first. return [ diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 29af8774db9c..5d1a52647fea 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -43,6 +43,7 @@ import { } from './ssh-relay-versioned-install' import { emptyOrcadActivationRecord, withActivatedVersion } from './orcad-activation-record' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { isReadinessRead } from './orcad-activation-host-test-harness' import type { SshConnection } from './ssh-connection' import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' @@ -127,7 +128,7 @@ function scriptHost(script: HostScript): void { if (text.includes('orcad.lock') && text.includes('orca-runtime.json')) { return 'CLEAR' } - if (text.includes('.orcad-readiness') && text.startsWith('head -c ')) { + if (text.includes('.orcad-readiness') && isReadinessRead(text)) { if (script.readinessAtMs !== undefined && Date.now() < script.readinessAtMs) { return '' } diff --git a/src/main/ssh/orcad-remote-gc.ts b/src/main/ssh/orcad-remote-gc.ts index 5f7664664e49..556bb35981b4 100644 --- a/src/main/ssh/orcad-remote-gc.ts +++ b/src/main/ssh/orcad-remote-gc.ts @@ -27,6 +27,7 @@ import { import type { RemoteHostPlatform } from './ssh-remote-platform' import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' import { readOrcadGcTransactionPins } from './orcad-gc-transaction-pins' +import { assertPosixOrcadHost } from './orcad-remote-host-support' export type OrcadGcOptions = { conn: SshConnection @@ -52,6 +53,8 @@ export type OrcadGcOptions = { } export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise { + // Windows liveness answers now, but GC there (one probe per pass, not per dir) is not built yet. + assertPosixOrcadHost(options.host) const transaction = await readOrcadGcTransactionPins(options) if (transaction.state === 'keep-all') { console.warn('[orcad-gc] An activation transaction is unreadable or unjournaled; skipping GC.') diff --git a/src/main/ssh/orcad-remote-host-support.ts b/src/main/ssh/orcad-remote-host-support.ts index 7ef8548ecdfd..cd00d71cc52a 100644 --- a/src/main/ssh/orcad-remote-host-support.ts +++ b/src/main/ssh/orcad-remote-host-support.ts @@ -3,9 +3,9 @@ * discovered at runtime. * * The install transaction is host-agnostic — it is the relay's, and the relay runs on - * Windows. The launch, liveness and stop path is not: it uses `nohup`, a redirected stdout, - * `kill -0` and `ps`. Emitting a PowerShell-shaped approximation of that would produce a - * deploy that reports success on a host where nothing is running. + * Windows. Launch, readiness, liveness, stop, build hash and record files have Windows + * branches; snapshots, the deploy and rollback drivers, preflight and managed stop do not yet, + * so the managed lifecycle still refuses Windows hosts at context resolution. */ import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' @@ -14,8 +14,8 @@ export class OrcadRemoteLaunchUnsupportedError extends Error { constructor(hostLabel: string) { super( `Deploying orcad to a ${hostLabel} host is not implemented. The install transaction is ` + - 'host-agnostic, but the launch and readiness path is POSIX-only: it uses nohup, a ' + - 'redirected stdout and `kill -0` liveness. Use the relay for this host.' + 'host-agnostic, but state snapshots, preflight, rollback and managed stop are ' + + 'POSIX-only. Use the relay for this host.' ) this.name = 'OrcadRemoteLaunchUnsupportedError' } @@ -27,8 +27,17 @@ export function assertPosixOrcadHost(host: RemoteHostPlatform): void { } } +/** Stdout of the launched candidate: exactly one `orca_server_ready` line, then nothing. */ +export const ORCAD_READINESS_FILENAME = '.orcad-readiness' +/** Stderr, including the bind-exposure line and every supervision message. */ +export const ORCAD_LOG_FILENAME = 'orcad.log' +// Why a cap: the readiness file is candidate-written stdout, and a runaway writer must not be read whole. +export const ORCAD_READINESS_MAX_BYTES = 256 * 1024 + /** PID of the launched orcad, written into its own version dir at launch. */ export const ORCAD_PID_FILENAME = '.orcad-pid' +/** Windows' `.orcad-pid`: `{"pid":N,"creationTimeMs":M|null}`, since a PID alone is no identity there. */ +export const ORCAD_WINDOWS_PROCESS_FILENAME = '.orcad-process.json' /** * A shell function answering whether a PID is a *running* process. diff --git a/src/main/ssh/orcad-remote-launch-windows.ts b/src/main/ssh/orcad-remote-launch-windows.ts new file mode 100644 index 000000000000..1a8c733063ea --- /dev/null +++ b/src/main/ssh/orcad-remote-launch-windows.ts @@ -0,0 +1,115 @@ +/** + * Starting a candidate orcad on a Windows SSH host. + * + * Win32-OpenSSH kills the session's job when the session ends, so orcad must leave that job. + * `orcad.js --windows-breakaway-launch` does it the way the relay does: one CreateProcessW with + * CREATE_BREAKAWAY_FROM_JOB through the slot's staged process-tree addon. Unlike the relay there + * is no WMI fallback: Win32_Process.Create is EDR-scored remote execution and refused to a + * standard user's network logon, so a host that cannot break away refuses the launch. + * + * Two execs, neither through PowerShell: the host script resolves the runtime the slot's marker + * names (and drops a stale stop request), then that node.exe runs the launcher with plain argv. + * The launcher records the PID with its creation time; a PID alone is not an identity on Windows. + */ +import { + ORCAD_WINDOWS_BREAKAWAY_CONTRACT, + parseWindowsBreakawayLaunchReport, + WINDOWS_BREAKAWAY_ENV_FLAG, + WINDOWS_BREAKAWAY_LAUNCH_FLAG, + WINDOWS_BREAKAWAY_PROCESS_FILE_FLAG, + WINDOWS_BREAKAWAY_STDERR_FLAG, + WINDOWS_BREAKAWAY_STDOUT_FLAG +} from '../../shared/windows-breakaway-launch' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { + orcadWindowsBaseDir, + orcadWindowsHostOpCommand, + orcadWindowsNodeCommandLine, + readOrcadWindowsEncodedAnswer +} from './orcad-remote-windows-node' +import { ORCAD_WINDOWS_RUNTIME_MARKER } from './orcad-windows-host-script' +import { + ORCAD_LOG_FILENAME, + ORCAD_READINESS_FILENAME, + ORCAD_WINDOWS_PROCESS_FILENAME +} from './orcad-remote-host-support' +import type { OrcadLaunchSpec } from './orcad-remote-launch' + +export class OrcadWindowsLaunchRefusedError extends Error { + readonly code = 'orcad_windows_launch_refused' + constructor(reason: string) { + super( + `The Windows host refused to start orcad outside the SSH session (${reason}). orcad needs ` + + 'a job that allows breakaway and a slot with the process-tree launcher; it never falls ' + + 'back to WMI.' + ) + this.name = 'OrcadWindowsLaunchRefusedError' + } +} + +/** Resolves the slot's node.exe and clears a stop request the previous process never consumed. */ +export function windowsOrcadLaunchRuntimeCommand( + host: RemoteHostPlatform, + slotDir: string +): string { + return orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, slotDir), 'slot-runtime', [ + slotDir, + 'clear-stop-request' + ]) +} + +export function readWindowsOrcadSlotRuntime(output: string): string { + const runtime = readOrcadWindowsEncodedAnswer(output, ORCAD_WINDOWS_RUNTIME_MARKER) + if (!runtime) { + throw new Error('The Windows host did not name the runtime this orcad slot needs.') + } + return runtime +} + +export function windowsOrcadLaunchCommand( + host: RemoteHostPlatform, + spec: OrcadLaunchSpec, + slotRuntime: string +): string { + const dir = spec.remoteInstallDir + return orcadWindowsNodeCommandLine(slotRuntime, [ + joinRemotePath(host, dir, 'orcad.js'), + WINDOWS_BREAKAWAY_LAUNCH_FLAG, + // The addon creates both with CREATE_ALWAYS, so a previous run's readiness line is gone + // before the launcher returns. + WINDOWS_BREAKAWAY_STDOUT_FLAG, + joinRemotePath(host, dir, ORCAD_READINESS_FILENAME), + WINDOWS_BREAKAWAY_STDERR_FLAG, + joinRemotePath(host, dir, ORCAD_LOG_FILENAME), + WINDOWS_BREAKAWAY_PROCESS_FILE_FLAG, + joinRemotePath(host, dir, ORCAD_WINDOWS_PROCESS_FILENAME), + WINDOWS_BREAKAWAY_ENV_FLAG, + `ORCA_VERSION=${spec.fullVersion}`, + WINDOWS_BREAKAWAY_ENV_FLAG, + `ORCA_USER_DATA=${spec.userDataDir}`, + ORCAD_WINDOWS_BREAKAWAY_CONTRACT.argsFlag, + '--json', + '--bind', + spec.bindHost, + '--port', + String(spec.port) + ]) +} + +/** The launched PID, or a refusal (no breakaway route) or failure the deploy must surface. */ +export function readWindowsOrcadLaunchReport(output: string): number { + const report = parseWindowsBreakawayLaunchReport(ORCAD_WINDOWS_BREAKAWAY_CONTRACT, output) + if (report?.method === 'breakaway') { + return report.pid + } + if (report?.method === 'unavailable') { + throw new OrcadWindowsLaunchRefusedError( + report.step ? `${report.reason} at ${report.step}` : report.reason + ) + } + const detail = + report?.method === 'failed' + ? `${report.reason}${report.step ? ` at ${report.step}` : ''} (code ${String(report.code ?? 0)})` + : 'no launch report' + throw new Error(`orcad's Windows launcher did not start the candidate: ${detail}.`) +} diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index 6e7a6644ec03..c133ddb4a88e 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -13,23 +13,27 @@ * still owns a running service. */ import { shellEscape } from './ssh-connection-utils' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { assertPosixOrcadHost as assertPosixHost, + ORCAD_LOG_FILENAME, ORCAD_PID_FILENAME, + ORCAD_READINESS_FILENAME, + ORCAD_READINESS_MAX_BYTES, posixProcessAliveShellFunction } from './orcad-remote-host-support' import type { ServeReadiness } from '../server/serve-readiness' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' +import { windowsOrcadLivenessProbeCommand } from './orcad-remote-liveness-windows' -/** Stdout of the launched candidate: exactly one `orca_server_ready` line, then nothing. */ -export const ORCAD_READINESS_FILENAME = '.orcad-readiness' -/** Stderr, including the bind-exposure line and every supervision message. */ -export const ORCAD_LOG_FILENAME = 'orcad.log' -// Why a cap: the readiness file is candidate-written stdout, and a runaway writer must not be read whole. -const ORCAD_READINESS_MAX_BYTES = 256 * 1024 -export { ORCAD_PID_FILENAME, OrcadRemoteLaunchUnsupportedError } from './orcad-remote-host-support' +export { + ORCAD_LOG_FILENAME, + ORCAD_PID_FILENAME, + ORCAD_READINESS_FILENAME, + ORCAD_READINESS_MAX_BYTES, + OrcadRemoteLaunchUnsupportedError +} from './orcad-remote-host-support' export type OrcadLaunchSpec = { remoteInstallDir: string @@ -98,7 +102,9 @@ export function orcadLivenessProbeCommand( host: RemoteHostPlatform, remoteInstallDir: string ): string { - assertPosixHost(host) + if (isWindowsRemoteHost(host)) { + return windowsOrcadLivenessProbeCommand(host, remoteInstallDir) + } const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) return [ posixProcessAliveShellFunction(), diff --git a/src/main/ssh/orcad-remote-liveness-windows.ts b/src/main/ssh/orcad-remote-liveness-windows.ts new file mode 100644 index 000000000000..b7b42d5d19d1 --- /dev/null +++ b/src/main/ssh/orcad-remote-liveness-windows.ts @@ -0,0 +1,21 @@ +/** + * Is the orcad a Windows slot launched still running? LIVE / DEAD / UNKNOWN, as on POSIX. + * + * A PID is not an identity on Windows, so the launcher's record pairs it with the process + * creation time and liveness needs both: the PID must be running (libuv's kill(pid, 0), which + * opens the process and reads its exit code) and the slot's process-tree addon must report the + * same creation time. A running PID with another creation time is a reused PID, so the recorded + * process is gone. Anything the host cannot answer is UNKNOWN, never DEAD. The rules live in the + * host script's `liveness` op. + */ +import { orcadWindowsBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +export function windowsOrcadLivenessProbeCommand( + host: RemoteHostPlatform, + remoteInstallDir: string +): string { + return orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, remoteInstallDir), 'liveness', [ + remoteInstallDir + ]) +} diff --git a/src/main/ssh/orcad-remote-primitives.test.ts b/src/main/ssh/orcad-remote-primitives.test.ts index 881f0db21f67..bc8e1bcf021f 100644 --- a/src/main/ssh/orcad-remote-primitives.test.ts +++ b/src/main/ssh/orcad-remote-primitives.test.ts @@ -103,10 +103,10 @@ describe('orcad host record files', () => { expect(String(mockExec.mock.calls.at(-1)?.[1])).toContain('rm -f') }) - it('refuses Windows hosts, which the orcad lifecycle does not support', async () => { + it('refuses a Windows read that names no pinned node.exe to run it', async () => { await expect( readBoundedOrcadRemoteRecord({ conn, host: windows }, 'C:/r.json', 64) - ).rejects.toThrow() + ).rejects.toThrow('pinned node.exe') expect(mockExec).not.toHaveBeenCalled() }) }) diff --git a/src/main/ssh/orcad-remote-process-control-windows.ts b/src/main/ssh/orcad-remote-process-control-windows.ts new file mode 100644 index 000000000000..5898a65fe013 --- /dev/null +++ b/src/main/ssh/orcad-remote-process-control-windows.ts @@ -0,0 +1,22 @@ +/** + * Stopping a Windows orcad: the slot's stop-request file, never a signal. + * + * `kill -TERM` and `process.kill(pid, 'SIGTERM')` are TerminateProcess on Windows, which skips + * the durable shutdown and leaves the instance lock behind, so a build whose readiness does not + * advertise `health.stopRequests` is refused rather than terminated. The host script's `stop` op + * answers with the POSIX command's tokens, plus UNSUPPORTED. + */ +import { orcadWindowsBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +export function windowsStopOrcadCommand( + host: RemoteHostPlatform, + remoteInstallDir: string, + options: { waitSeconds: number; justLaunched: boolean } +): string { + return orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, remoteInstallDir), 'stop', [ + remoteInstallDir, + String(options.waitSeconds), + options.justLaunched ? '1' : '0' + ]) +} diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index da4cf9687063..040e4bd2afb2 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -5,18 +5,15 @@ * current owner; SIGKILL would skip flushing state and releasing the instance lock. */ import { shellEscape } from './ssh-connection-utils' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' import { ORCAD_STOP_REQUEST_FILENAME, ORCAD_STOP_REQUESTS_CAPABILITY } from '../../shared/orcad-stop-request' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { - assertPosixOrcadHost as assertPosixHost, - ORCAD_PID_FILENAME, - posixProcessAliveShellFunction -} from './orcad-remote-host-support' +import { ORCAD_PID_FILENAME, posixProcessAliveShellFunction } from './orcad-remote-host-support' +import { windowsStopOrcadCommand } from './orcad-remote-process-control-windows' /** * Ask the orcad recorded in a version dir to stop, and wait for it to go. @@ -25,6 +22,7 @@ import { * request file, which only that orcad watches; older builds keep receiving SIGTERM. Both need * the readiness PID to corroborate the launcher's PID first, so a reused PID is never stopped. * `justLaunched` is only for this client's fixed exec launcher, including pre-readiness exits. + * Windows has no graceful signal, so it only ever writes the request file. */ export function stopOrcadCommand( host: RemoteHostPlatform, @@ -34,7 +32,12 @@ export function stopOrcadCommand( | { justLaunched?: false; nodePath: string } ) ): string { - assertPosixHost(host) + if (isWindowsRemoteHost(host)) { + return windowsStopOrcadCommand(host, remoteInstallDir, { + waitSeconds: options.waitSeconds, + justLaunched: options.justLaunched === true + }) + } const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) const requestFile = shellEscape( @@ -78,6 +81,8 @@ export type OrcadStopOutcome = | 'no-pid' | 'still-running' | 'signal-failed' + /** Windows only: the build cannot be asked to stop, and terminating it would skip shutdown. */ + | 'unsupported' | 'unknown' export function parseOrcadStopOutcome(output: string): OrcadStopOutcome { @@ -92,6 +97,8 @@ export function parseOrcadStopOutcome(output: string): OrcadStopOutcome { return 'still-running' case 'SIGNAL_FAILED': return 'signal-failed' + case 'UNSUPPORTED': + return 'unsupported' default: return 'unknown' } diff --git a/src/main/ssh/orcad-remote-readiness-wait.ts b/src/main/ssh/orcad-remote-readiness-wait.ts new file mode 100644 index 000000000000..e231bf1697c8 --- /dev/null +++ b/src/main/ssh/orcad-remote-readiness-wait.ts @@ -0,0 +1,80 @@ +/** + * Waiting for a launched orcad's readiness line on the host, not across SSH. + * + * The client used to re-read the readiness file every 500 ms, one exec each, for up to three + * minutes. A burst of short-lived processes under sshd is itself an EDR signal on Windows + * (docs/reference/windows-edr-posture.md). One exec now waits host-side for a complete line, an + * oversized file, or its own bounded deadline. + */ +import { shellEscape } from './ssh-connection-utils' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { + ORCAD_READINESS_FILENAME, + ORCAD_READINESS_MAX_BYTES, + parseOrcadReadinessOutput, + type OrcadReadinessParse +} from './orcad-remote-launch' +import { + orcadWindowsBaseDir, + orcadWindowsHostOpCommand, + readOrcadWindowsEncodedAnswer +} from './orcad-remote-windows-node' +import { ORCAD_WINDOWS_READINESS_MARKER } from './orcad-windows-host-script' + +/** Under the 30 s exec timeout, so one wait never reads as an unanswered host. */ +export const ORCAD_READINESS_WAIT_MAX_SECONDS = 20 + +/** Settles on a newline (a finished line) or more than the cap; both are final for the parser. */ +function posixReadinessWaitCommand( + host: RemoteHostPlatform, + remoteInstallDir: string, + waitSeconds: number +): string { + const file = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) + const read = `head -c ${ORCAD_READINESS_MAX_BYTES + 1} ${file} 2>/dev/null` + return [ + // Fractional sleep is not POSIX; probe it once so the deadline stays in seconds either way. + 'if sleep 0.25 2>/dev/null; then orcad_readiness_wait_step=0.25; orcad_readiness_wait_per=4;', + 'else orcad_readiness_wait_step=1; orcad_readiness_wait_per=1; fi;', + `orcad_readiness_wait_left=$((${waitSeconds} * orcad_readiness_wait_per));`, + 'while [ "$orcad_readiness_wait_left" -gt 0 ]; do', + `[ "$(${read} | wc -l)" -gt 0 ] && break;`, + `[ "$(${read} | wc -c)" -gt ${ORCAD_READINESS_MAX_BYTES} ] && break;`, + 'sleep "$orcad_readiness_wait_step";', + 'orcad_readiness_wait_left=$((orcad_readiness_wait_left - 1)); done;', + `${read} || true` + ].join(' ') +} + +export function orcadReadinessWaitCommand( + host: RemoteHostPlatform, + remoteInstallDir: string, + waitSeconds: number +): string { + const seconds = Math.max(0, Math.min(ORCAD_READINESS_WAIT_MAX_SECONDS, Math.ceil(waitSeconds))) + if (!isWindowsRemoteHost(host)) { + return posixReadinessWaitCommand(host, remoteInstallDir, seconds) + } + return orcadWindowsHostOpCommand( + host, + orcadWindowsBaseDir(host, remoteInstallDir), + 'readiness-wait', + [ + joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME), + String(ORCAD_READINESS_MAX_BYTES), + String(seconds) + ] + ) +} + +/** What the readiness file held when the host stopped waiting; `pending` if still unfinished. */ +export function parseOrcadReadinessWaitOutput( + host: RemoteHostPlatform, + output: string +): OrcadReadinessParse { + return parseOrcadReadinessOutput( + isWindowsRemoteHost(host) + ? (readOrcadWindowsEncodedAnswer(output, ORCAD_WINDOWS_READINESS_MARKER) ?? '') + : output + ) +} diff --git a/src/main/ssh/orcad-remote-record-file.ts b/src/main/ssh/orcad-remote-record-file.ts index 5c29c3b3ef5c..c1cacb3a2a62 100644 --- a/src/main/ssh/orcad-remote-record-file.ts +++ b/src/main/ssh/orcad-remote-record-file.ts @@ -6,23 +6,41 @@ */ import { randomUUID } from 'node:crypto' import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' import { removeRemoteFileCommand } from './ssh-remote-commands' import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' - -const ABSENT_MARKER = '__ORCAD_RECORD_ABSENT__' -const PRESENT_MARKER = '__ORCAD_RECORD_PRESENT__' +import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + orcadWindowsHostOpCommand, + readOrcadWindowsEncodedAnswer +} from './orcad-remote-windows-node' +import { + ORCAD_RECORD_ABSENT_MARKER as ABSENT_MARKER, + ORCAD_RECORD_PRESENT_MARKER as PRESENT_MARKER +} from './orcad-windows-host-script' /** `present` carries raw bytes; schema checks belong to the caller that owns the format. */ export type OrcadRemoteRecordRead = { state: 'absent' } | { state: 'present'; raw: string } +/** `~/.orca-remote`, where the host script and the pinned runtime live. */ +function windowsBaseDir(target: OrcadRemoteExecTarget): string { + if (!target.remoteHome) { + throw new Error( + 'orcad host records on Windows need the remote home to find the pinned node.exe' + ) + } + return joinRemotePath(target.host, target.remoteHome, RELAY_REMOTE_DIR) +} + export async function readBoundedOrcadRemoteRecord( target: OrcadRemoteExecTarget, path: string, maxBytes: number ): Promise { - assertPosixOrcadHost(target.host) + if (isWindowsRemoteHost(target.host)) { + return readWindowsRecord(target, path, maxBytes) + } const file = shellEscape(path) // Why markers: an empty stdout must never be mistaken for "no record" when the read failed. const output = await execOrcadRemote( @@ -43,14 +61,47 @@ export async function readBoundedOrcadRemoteRecord( return { state: 'present', raw: newline === -1 ? '' : output.slice(newline + 1) } } +async function readWindowsRecord( + target: OrcadRemoteExecTarget, + path: string, + maxBytes: number +): Promise { + const output = await execOrcadRemote( + target, + orcadWindowsHostOpCommand(target.host, windowsBaseDir(target), 'record-read', [ + path, + String(maxBytes) + ]) + ) + if (output.split(/\r?\n/u).some((line) => line.trim() === ABSENT_MARKER)) { + return { state: 'absent' } + } + const raw = readOrcadWindowsEncodedAnswer(output, PRESENT_MARKER) + if (raw === null) { + throw new Error('orcad host record read returned no verifiable answer') + } + return { state: 'present', raw } +} + export async function writeAtomicOrcadRemoteRecord( target: OrcadRemoteExecTarget, path: string, contents: string ): Promise { - assertPosixOrcadHost(target.host) const partialPath = `${path}.partial.${process.pid}.${randomUUID()}` + const baseDir = isWindowsRemoteHost(target.host) ? windowsBaseDir(target) : null try { + if (baseDir) { + await target.conn.writeFile(partialPath, contents, { + hostPlatform: target.host, + signal: target.signal + }) + await execOrcadRemote( + target, + orcadWindowsHostOpCommand(target.host, baseDir, 'record-publish', [partialPath, path]) + ) + return + } await execOrcadRemote( target, `umask 077; printf %s ${shellEscape(contents)} > ${shellEscape(partialPath)} && ` + @@ -59,9 +110,10 @@ export async function writeAtomicOrcadRemoteRecord( } catch (error) { // Why keep the partial on an unconfirmed termination: the write may still be running there. if (!isUnconfirmedSshCommandTermination(error)) { - await execOrcadRemote(target, removeRemoteFileCommand(target.host, partialPath)).catch( - () => {} - ) + const discard = baseDir + ? orcadWindowsHostOpCommand(target.host, baseDir, 'remove-file', [partialPath]) + : removeRemoteFileCommand(target.host, partialPath) + await execOrcadRemote(target, discard).catch(() => {}) } throw error } diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts index 02aada05b2b1..57a926a16fea 100644 --- a/src/main/ssh/orcad-remote-rollback.test.ts +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -25,6 +25,7 @@ import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' import { rollbackOrcad, type OrcadRollbackOptions } from './orcad-remote-rollback' import { emptyOrcadActivationRecord, type OrcadActivationRecord } from './orcad-activation-record' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { isReadinessRead } from './orcad-activation-host-test-harness' import type { SshConnection } from './ssh-connection' const mockExec = vi.mocked(execCommand) @@ -124,7 +125,7 @@ function scriptHost(log: string[], overrides: HostOverrides = {}): void { log.push(`launch:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) return '9999' } - if (text.startsWith('head -c ') && text.includes('.orcad-readiness')) { + if (isReadinessRead(text) && text.includes('.orcad-readiness')) { if (overrides.readinessAtMs !== undefined && Date.now() < overrides.readinessAtMs) { return '' } diff --git a/src/main/ssh/orcad-remote-runtime-control.ts b/src/main/ssh/orcad-remote-runtime-control.ts index e3ddc1566bf7..f229c97d1862 100644 --- a/src/main/ssh/orcad-remote-runtime-control.ts +++ b/src/main/ssh/orcad-remote-runtime-control.ts @@ -1,22 +1,35 @@ -/** Launch a slot and poll its readiness file: the one loop deploy, rollback and recovery share. */ +/** Launch a slot and wait for its readiness: the one loop deploy, rollback and recovery share. */ import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { orcadLaunchCommand, parseOrcadReadinessOutput, - readOrcadReadinessCommand, type OrcadLaunchSpec, type OrcadReadinessParse } from './orcad-remote-launch' +import { + readWindowsOrcadLaunchReport, + readWindowsOrcadSlotRuntime, + windowsOrcadLaunchCommand, + windowsOrcadLaunchRuntimeCommand +} from './orcad-remote-launch-windows' +import { + ORCAD_READINESS_WAIT_MAX_SECONDS, + orcadReadinessWaitCommand, + parseOrcadReadinessWaitOutput +} from './orcad-remote-readiness-wait' import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' -import type { RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -const READINESS_POLL_MS = 500 +// Only between host-side waits, so a host that answers early cannot turn this into a tight loop. +const READINESS_RETRY_PAUSE_MS = 1_000 export type OrcadRemoteExecTarget = { conn: SshConnection host: RemoteHostPlatform signal?: AbortSignal + /** Locates the runtime store for host-record scripts; required on Windows, unused elsewhere. */ + remoteHome?: string } export function execOrcadRemote( @@ -45,19 +58,39 @@ export async function launchOrcadAndAwaitReadiness( }, spec: OrcadLaunchSpec ): Promise { - await execOrcadRemote(target, orcadLaunchCommand(target.host, spec)) + if (isWindowsRemoteHost(target.host)) { + const slotRuntime = readWindowsOrcadSlotRuntime( + await execOrcadRemote( + target, + windowsOrcadLaunchRuntimeCommand(target.host, spec.remoteInstallDir) + ) + ) + readWindowsOrcadLaunchReport( + await execOrcadRemote(target, windowsOrcadLaunchCommand(target.host, spec, slotRuntime)) + ) + } else { + await execOrcadRemote(target, orcadLaunchCommand(target.host, spec)) + } const deadline = Date.now() + (target.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) const sleep = target.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) let last = parseOrcadReadinessOutput('') while (Date.now() < deadline) { target.signal?.throwIfAborted() - last = parseOrcadReadinessOutput( - await execOrcadRemote(target, readOrcadReadinessCommand(target.host, spec.remoteInstallDir)) + const waitSeconds = Math.min( + ORCAD_READINESS_WAIT_MAX_SECONDS, + Math.ceil((deadline - Date.now()) / 1000) + ) + last = parseOrcadReadinessWaitOutput( + target.host, + await execOrcadRemote( + target, + orcadReadinessWaitCommand(target.host, spec.remoteInstallDir, waitSeconds) + ) ) if (last.state !== 'pending') { return last } - await sleep(READINESS_POLL_MS) + await sleep(READINESS_RETRY_PAUSE_MS) } return last } diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index 0b0cab2a7a8d..1ca251a2e1ba 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -37,6 +37,11 @@ import { parseOrcadStopOutcome, stopOrcadCommand } from './orcad-remote-process-control' +import { shellEscape } from './ssh-connection-utils' +import { + orcadReadinessWaitCommand, + parseOrcadReadinessWaitOutput +} from './orcad-remote-readiness-wait' import { captureOrcadStateSnapshotCommand, compareOrcadStateSnapshotCommand, @@ -512,3 +517,43 @@ describe('liveness and stop commands, run for real', () => { ).toBe('no-pid') }) }) + +describe('host-side readiness wait, run for real', () => { + const readiness = () => join(versionDir, ORCAD_READINESS_FILENAME) + const line = `${JSON.stringify({ type: 'orca_server_ready', runtimeId: 'r1' })}\n` + + it('returns a finished line without waiting out its bound', () => { + writeFileSync(readiness(), line) + const started = Date.now() + const result = parseOrcadReadinessWaitOutput( + host, + sh(orcadReadinessWaitCommand(host, versionDir, 10)) + ) + expect(Date.now() - started).toBeLessThan(5_000) + expect(result).toMatchObject({ state: 'ready', readiness: { runtimeId: 'r1' } }) + }) + + it('waits for a line still being written, and answers pending when its bound ends', () => { + writeFileSync(readiness(), line.slice(0, 10)) + // The writer finishes after the wait starts; the shell must pick it up mid-wait. + sh( + `(sleep 1; printf '%s\\n' ${shellEscape(line.trimEnd().slice(10))} >> ${shellEscape(readiness())}) >/dev/null 2>&1 &` + ) + expect( + parseOrcadReadinessWaitOutput(host, sh(orcadReadinessWaitCommand(host, versionDir, 10))) + ).toMatchObject({ state: 'ready' }) + writeFileSync(readiness(), '{"type":"orca_ser') + expect( + parseOrcadReadinessWaitOutput(host, sh(orcadReadinessWaitCommand(host, versionDir, 1))) + ).toEqual({ state: 'pending' }) + }) + + it('reads a missing file as pending', () => { + expect( + parseOrcadReadinessWaitOutput( + host, + sh(orcadReadinessWaitCommand(host, `${versionDir}-none`, 0)) + ) + ).toEqual({ state: 'pending' }) + }) +}) diff --git a/src/main/ssh/orcad-remote-windows-commands.test.ts b/src/main/ssh/orcad-remote-windows-commands.test.ts new file mode 100644 index 000000000000..83f7287c6ab1 --- /dev/null +++ b/src/main/ssh/orcad-remote-windows-commands.test.ts @@ -0,0 +1,309 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + isUnconfirmedSshCommandTermination: () => false +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { orcadLivenessProbeCommand, type OrcadLaunchSpec } from './orcad-remote-launch' +import { + OrcadWindowsLaunchRefusedError, + readWindowsOrcadLaunchReport, + windowsOrcadLaunchCommand, + windowsOrcadLaunchRuntimeCommand +} from './orcad-remote-launch-windows' +import { parseOrcadStopOutcome, stopOrcadCommand } from './orcad-remote-process-control' +import { orcadReadinessWaitCommand } from './orcad-remote-readiness-wait' +import { remoteOrcadBuildHashCommand, readRemoteOrcadBuildHash } from './orcad-remote-build-hash' +import { + readBoundedOrcadRemoteRecord, + writeAtomicOrcadRemoteRecord +} from './orcad-remote-record-file' +import { launchOrcadAndAwaitReadiness } from './orcad-remote-runtime-control' +import { completeRemoteOrcadManagedStop } from './orcad-managed-remote-stop' +import { + installOrcadWindowsHostScript, + orcadWindowsNodeCommandLine, + orcadWindowsPinnedNodePath, + OrcadWindowsCommandLineError +} from './orcad-remote-windows-node' +import { + ORCAD_WINDOWS_HOST_SCRIPT, + ORCAD_WINDOWS_HOST_SCRIPT_FILENAME +} from './orcad-windows-host-script' +import type { OrcadSlotOptions } from './orcad-recovery-slot' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' + +const mockExec = vi.mocked(execCommand) +const host = getRemoteHostPlatform('win32-x64') +const base = 'C:/Users/u/.orca-remote' +const slot = `${base}/orcad-0.2.0+bb01` +const sha = NODE_RUNTIME_ASSETS['win32-x64'].executableSha256 +const NODE = `C:\\Users\\u\\.orca-remote\\runtimes\\node-${sha}\\node.exe` +const SCRIPT = `${base}/${ORCAD_WINDOWS_HOST_SCRIPT_FILENAME}` +const SLOT_NODE = 'C:\\Users\\u\\.orca-remote\\runtimes\\node-ab\\node.exe' +const spec: OrcadLaunchSpec = { + remoteInstallDir: slot, + nodePath: 'C:/host/node.exe', + fullVersion: '0.2.0+bb01', + userDataDir: 'C:/Users/u/.orca', + bindHost: '127.0.0.1', + port: 7777 +} + +function windowsConn(): { conn: SshConnection; writes: [string, string][] } { + const writes: [string, string][] = [] + const conn: SshConnection = Object.assign(Object.create(null), { + writeFile: async (path: string, contents: string) => { + writes.push([path, contents]) + } + }) + return { conn, writes } +} + +function encoded(marker: string, value: string): string { + return `${marker} ${Buffer.from(value).toString('base64')}\r\n` +} + +beforeEach(() => { + mockExec.mockReset() +}) + +describe('Windows orcad commands run node.exe directly', () => { + const commands = (): [string, string][] => [ + ['launch runtime', windowsOrcadLaunchRuntimeCommand(host, slot)], + ['launch', windowsOrcadLaunchCommand(host, spec, SLOT_NODE)], + ['readiness wait', orcadReadinessWaitCommand(host, slot, 20)], + ['liveness', orcadLivenessProbeCommand(host, slot)], + ['stop', stopOrcadCommand(host, slot, { waitSeconds: 20, nodePath: spec.nodePath })], + ['build hash', remoteOrcadBuildHashCommand(host, slot)] + ] + + it.each(commands())('%s: no PowerShell hop, no encoding, no WMI or signal', (_name, command) => { + expect(command).toMatch(/^C:\\Users\\u\\\.orca-remote\\runtimes\\node-[0-9a-f]+\\node\.exe /u) + expect(command).not.toMatch( + /EncodedCommand|powershell|pwsh|cmd\.exe|ExecutionPolicy|Cim|Wmi|taskkill|SIGTERM|kill -/iu + ) + // Nothing either DefaultShell expands, and nothing a script would need quoting for. + expect(command).not.toMatch(/[%$`']/u) + }) + + it('host ops use the client pin; only the launch uses the slot runtime', () => { + expect(orcadWindowsPinnedNodePath(host, base)).toBe(`${base}/runtimes/node-${sha}/node.exe`) + expect(orcadLivenessProbeCommand(host, slot)).toBe(`${NODE} ${SCRIPT} liveness ${slot}`) + expect(stopOrcadCommand(host, slot, { waitSeconds: 20, justLaunched: true })).toBe( + `${NODE} ${SCRIPT} stop ${slot} "20" "1"` + ) + }) + + it('golden: the two launch lines', () => { + expect(windowsOrcadLaunchRuntimeCommand(host, slot)).toBe( + `${NODE} ${SCRIPT} slot-runtime ${slot} clear-stop-request` + ) + expect(windowsOrcadLaunchCommand(host, spec, SLOT_NODE)).toMatchInlineSnapshot( + `"C:\\Users\\u\\.orca-remote\\runtimes\\node-ab\\node.exe C:/Users/u/.orca-remote/orcad-0.2.0+bb01/orcad.js --windows-breakaway-launch --stdout-file C:/Users/u/.orca-remote/orcad-0.2.0+bb01/.orcad-readiness --stderr-file C:/Users/u/.orca-remote/orcad-0.2.0+bb01/orcad.log --process-file C:/Users/u/.orca-remote/orcad-0.2.0+bb01/.orcad-process.json --env ORCA_VERSION=0.2.0+bb01 --env ORCA_USER_DATA=C:/Users/u/.orca --orcad-args --json --bind "127.0.0.1" --port "7777""` + ) + }) + + it('never polls across SSH: runtime, launch, then one host-side wait', async () => { + mockExec + .mockResolvedValueOnce(encoded('__ORCAD_RUNTIME__', SLOT_NODE)) + .mockResolvedValueOnce( + 'ORCA_ORCAD_LAUNCH {"method":"breakaway","pid":4242,"inJob":false}\r\n' + ) + .mockResolvedValueOnce( + encoded( + '__ORCAD_READINESS__', + `${JSON.stringify({ type: 'orca_server_ready', runtimeId: 'r1' })}\n` + ) + ) + const sleep = vi.fn(async () => {}) + const result = await launchOrcadAndAwaitReadiness( + { conn: Object.create(null), host, readinessTimeoutMs: 60_000, sleep }, + spec + ) + expect(result).toMatchObject({ state: 'ready', readiness: { runtimeId: 'r1' } }) + expect(mockExec).toHaveBeenCalledTimes(3) + expect(String(mockExec.mock.calls[1]?.[1]).startsWith(`${SLOT_NODE} `)).toBe(true) + expect(sleep).not.toHaveBeenCalled() + }) +}) + +describe('Windows command lines for both DefaultShells', () => { + it('double-quotes arguments with spaces or leading digits, and passes the rest bare', () => { + expect( + orcadWindowsNodeCommandLine('C:/rt/node.exe', ['C:/Users/Ann Lee/x', '20', 'stop']) + ).toBe('C:\\rt\\node.exe "C:/Users/Ann Lee/x" "20" stop') + }) + + it('falls back to one unencoded powershell -Command when node.exe itself needs quoting', () => { + expect( + orcadWindowsNodeCommandLine("C:/Users/Ann O'Lee/rt/node.exe", [ + "C:/Users/Ann O'Lee/x", + 'stop' + ]) + ).toBe( + `powershell.exe -NoProfile -NonInteractive -Command "& 'C:\\Users\\Ann O''Lee\\rt\\node.exe' 'C:/Users/Ann O''Lee/x' 'stop'"` + ) + }) + + it.each(['C:/Users/100%/x', 'C:/Users/$me/x', 'C:/Users/a`b/x'])( + 'refuses %s rather than letting a shell expand it', + (value) => { + expect(() => orcadWindowsNodeCommandLine('C:/rt/node.exe', [value])).toThrow( + OrcadWindowsCommandLineError + ) + } + ) +}) + +describe('no -EncodedCommand in the W1 builders', () => { + it.each([ + 'orcad-remote-windows-node.ts', + 'orcad-windows-host-script.ts', + 'orcad-remote-launch-windows.ts', + 'orcad-remote-liveness-windows.ts', + 'orcad-remote-process-control-windows.ts', + 'orcad-remote-readiness-wait.ts', + 'orcad-remote-record-file.ts', + 'orcad-remote-build-hash.ts', + 'orcad-managed-remote-stop.ts', + 'orcad-remote-runtime-control.ts' + ])('%s', (file) => { + const code = readFileSync(join(__dirname, file), 'utf8') + .replace(/\/\*[\s\S]*?\*\//gu, '') + .replace(/^\s*\/\/.*$/gmu, '') + expect(code).not.toMatch(/EncodedCommand|powerShellCommand/u) + }) +}) + +describe('Windows launch report', () => { + it('reads a PID, a refusal, or a failure', () => { + expect( + readWindowsOrcadLaunchReport('ORCA_ORCAD_LAUNCH {"method":"breakaway","pid":9,"inJob":false}') + ).toBe(9) + expect(() => + readWindowsOrcadLaunchReport( + 'ORCA_ORCAD_LAUNCH {"method":"unavailable","reason":"breakaway-denied","step":"create-process","code":5}' + ) + ).toThrow(OrcadWindowsLaunchRefusedError) + expect(() => + readWindowsOrcadLaunchReport( + 'ORCA_ORCAD_LAUNCH {"method":"failed","reason":"failed","step":"open-stdout","code":32}' + ) + ).toThrow('open-stdout (code 32)') + // The relay's report is not orcad's. + expect(() => + readWindowsOrcadLaunchReport('ORCA_RELAY_LAUNCH {"method":"breakaway","pid":9,"inJob":false}') + ).toThrow('no launch report') + }) + + it('reads UNSUPPORTED as a stop refusal', () => { + expect(parseOrcadStopOutcome('UNSUPPORTED')).toBe('unsupported') + }) +}) + +describe('Windows build hash and host script', () => { + it('reads the same marker as POSIX', async () => { + mockExec.mockResolvedValueOnce('__ORCAD_BUILD_HASH__ ABC123DEF4567890\r\n') + await expect(readRemoteOrcadBuildHash({ conn: Object.create(null), host }, slot)).resolves.toBe( + 'abc123def4567890' + ) + }) + + it('stages the host script by file write, content-addressed beside the slots', async () => { + const { conn, writes } = windowsConn() + await installOrcadWindowsHostScript({ conn, host }, base) + expect(writes).toEqual([[SCRIPT, ORCAD_WINDOWS_HOST_SCRIPT]]) + expect(mockExec).not.toHaveBeenCalled() + }) +}) + +describe('Windows host records', () => { + it('decodes a present record and reads absent as absent', async () => { + const { conn } = windowsConn() + const target = { conn, host, remoteHome: 'C:/Users/u' } + mockExec.mockResolvedValueOnce(encoded('__ORCAD_RECORD_PRESENT__', '{"owner":"Zoë"}')) + await expect(readBoundedOrcadRemoteRecord(target, 'C:/r.json', 64)).resolves.toEqual({ + state: 'present', + raw: '{"owner":"Zoë"}' + }) + expect(String(mockExec.mock.calls[0]?.[1])).toBe(`${NODE} ${SCRIPT} record-read C:/r.json "64"`) + mockExec.mockResolvedValueOnce('__ORCAD_RECORD_ABSENT__\r\n') + await expect(readBoundedOrcadRemoteRecord(target, 'C:/r.json', 64)).resolves.toEqual({ + state: 'absent' + }) + mockExec.mockResolvedValueOnce('') + await expect(readBoundedOrcadRemoteRecord(target, 'C:/r.json', 64)).rejects.toThrow( + 'no verifiable answer' + ) + }) + + it('refuses a Windows record without the remote home that locates node.exe', async () => { + await expect( + readBoundedOrcadRemoteRecord({ conn: Object.create(null), host }, 'C:/r.json', 64) + ).rejects.toThrow('remote home') + }) + + it('stages the contents as a file and never puts them on a command line', async () => { + const { conn, writes } = windowsConn() + mockExec.mockResolvedValueOnce('') + const contents = '{"secret-ish":"record body"}' + await writeAtomicOrcadRemoteRecord( + { conn, host, remoteHome: 'C:/Users/u' }, + 'C:/r.json', + contents + ) + expect(writes).toHaveLength(1) + expect(writes[0]?.[0]).toMatch(/^C:\/r\.json\.partial\./u) + expect(writes[0]?.[1]).toBe(contents) + const command = String(mockExec.mock.calls[0]?.[1]) + expect(command).not.toContain('record body') + expect(command.startsWith(`${NODE} ${SCRIPT} record-publish `)).toBe(true) + }) +}) + +describe('Windows managed stop', () => { + it('hands orcad a staged request file, not JSON on argv', async () => { + const { conn, writes } = windowsConn() + const request = { + schemaVersion: 1 as const, + transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', + version: '0.2.0+bb01', + runtimeId: 'r1', + instance: { pid: 9, startedAtMs: 5, nonce: 'n', lockPath: 'C:/Users/u/.orca/orcad.lock' } + } + mockExec + .mockResolvedValueOnce(encoded('__ORCAD_RUNTIME__', SLOT_NODE)) + .mockResolvedValueOnce( + `${JSON.stringify({ ...request, kind: 'orcad_managed_stop_completion', verdict: 'live', receiptPersisted: false })}\r\n` + ) + .mockResolvedValueOnce('') + const options: OrcadSlotOptions = { + conn, + host, + remoteHome: 'C:/Users/u', + nodePath: 'C:/host/node.exe', + userDataDir: 'C:/Users/u/.orca', + bindHost: '127.0.0.1', + port: 7777 + } + await expect(completeRemoteOrcadManagedStop(options, request)).resolves.toMatchObject({ + verdict: 'live' + }) + const [stagedPath, stagedBody] = writes[0] ?? ['', ''] + expect(JSON.parse(stagedBody)).toEqual(request) + expect(String(mockExec.mock.calls[0]?.[1])).toBe(`${NODE} ${SCRIPT} slot-runtime ${slot}`) + const run = String(mockExec.mock.calls[1]?.[1]) + expect(run).not.toContain(request.transactionId) + expect(run).toBe( + `${SLOT_NODE} ${slot}/orcad.js --complete-managed-stop --request-file ${stagedPath}` + ) + expect(String(mockExec.mock.calls[2]?.[1])).toBe(`${NODE} ${SCRIPT} remove-file ${stagedPath}`) + }) +}) diff --git a/src/main/ssh/orcad-remote-windows-node.ts b/src/main/ssh/orcad-remote-windows-node.ts new file mode 100644 index 000000000000..c1913f06734f --- /dev/null +++ b/src/main/ssh/orcad-remote-windows-node.ts @@ -0,0 +1,140 @@ +/** + * Running node.exe on a Windows orcad host straight from the SSH exec, with plain argv. + * + * sshd hands the command to its DefaultShell, cmd.exe on a stock install or PowerShell when an + * admin sets it, and Orca does not probe which. So the line is built from the subset both parse + * the same way: an unquoted executable path, then arguments that are bare or double-quoted and + * contain nothing either shell expands (`%`, `$`, backtick, `"`). When the executable path + * itself needs quoting (a profile with a space), the line falls back to one unencoded + * `powershell.exe -Command`, which both shells pass through intact. Anything else is refused + * rather than encoded (docs/reference/windows-edr-posture.md). + */ +import { + ORCAD_NODE_RUNTIME_DIR_PREFIX, + ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE, + ORCAD_RUNTIMES_DIRNAME +} from '../../shared/orcad-artifacts' +import { pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../shared/node-runtime-pin' +import type { SshConnection } from './ssh-connection' +import { joinRemotePath, remoteDirname, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellLiteral } from './ssh-remote-powershell' +import { + ORCAD_WINDOWS_HOST_SCRIPT, + ORCAD_WINDOWS_HOST_SCRIPT_FILENAME, + type OrcadWindowsHostOp +} from './orcad-windows-host-script' + +const BARE_EXECUTABLE = /^[A-Za-z]:\\[A-Za-z0-9._+~\\-]*$/u +// Leading digits are quoted: PowerShell would read `1e5` as a number and pass `100000`. +const BARE_ARGUMENT = /^[A-Za-z-][A-Za-z0-9._:+=/\\-]*$/u +// Expanded by cmd.exe (`%`) or PowerShell (`$`, backtick, typographic quotes) inside "..." +const UNQUOTABLE = /["%$`\r\n“”„‘’‚‛]/u + +export class OrcadWindowsCommandLineError extends Error { + readonly code = 'orcad_windows_command_line_unsafe' + constructor(value: string) { + super( + `Orca cannot pass ${JSON.stringify(value)} to node.exe on this Windows host: it contains ` + + 'a character cmd.exe or PowerShell would expand.' + ) + this.name = 'OrcadWindowsCommandLineError' + } +} + +function windowsPath(value: string): string { + return value.replace(/\//gu, '\\') +} + +function quotedArgument(value: string): string { + if (BARE_ARGUMENT.test(value)) { + return value + } + // A trailing backslash would escape the closing quote for CommandLineToArgvW. + if (value === '' || UNQUOTABLE.test(value) || value.endsWith('\\')) { + throw new OrcadWindowsCommandLineError(value) + } + return `"${value}"` +} + +/** One node.exe invocation that parses identically under cmd.exe and PowerShell. */ +export function orcadWindowsNodeCommandLine(executable: string, args: readonly string[]): string { + const program = windowsPath(executable) + const argv = args.map(quotedArgument) + if (BARE_EXECUTABLE.test(program)) { + return [program, ...argv].join(' ') + } + // The inner line is single-quoted PowerShell inside one double-quoted argument both shells keep. + for (const value of [program, ...args]) { + if (UNQUOTABLE.test(value)) { + throw new OrcadWindowsCommandLineError(value) + } + } + const inner = ['&', ...[program, ...args].map(powerShellLiteral)].join(' ') + return `powershell.exe -NoProfile -NonInteractive -Command "${inner}"` +} + +/** `~/.orca-remote`, the parent of every slot and of the runtime store. */ +export function orcadWindowsBaseDir(host: RemoteHostPlatform, slotDir: string): string { + return remoteDirname(slotDir.replace(/\/+$/u, ''), host) +} + +/** This client's pinned node.exe in the runtime store; deterministic from the host's arch. */ +export function orcadWindowsPinnedNodePath(host: RemoteHostPlatform, baseDir: string): string { + const target: NodeRuntimeTarget = host.arch === 'arm64' ? 'win32-arm64' : 'win32-x64' + return joinRemotePath( + host, + baseDir, + ORCAD_RUNTIMES_DIRNAME, + `${ORCAD_NODE_RUNTIME_DIR_PREFIX}${pinnedNodeRuntimeAsset(target).executableSha256}`, + ORCAD_NODE_RUNTIME_WINDOWS_EXECUTABLE + ) +} + +export function orcadWindowsHostScriptPath(host: RemoteHostPlatform, baseDir: string): string { + return joinRemotePath(host, baseDir, ORCAD_WINDOWS_HOST_SCRIPT_FILENAME) +} + +/** `node.exe `, run by this client's pinned runtime. */ +export function orcadWindowsHostOpCommand( + host: RemoteHostPlatform, + baseDir: string, + op: OrcadWindowsHostOp, + args: readonly string[] +): string { + return orcadWindowsNodeCommandLine(orcadWindowsPinnedNodePath(host, baseDir), [ + orcadWindowsHostScriptPath(host, baseDir), + op, + ...args + ]) +} + +/** + * Stages the host script. One file write per lifecycle operation, before any host op: the + * caller that resolves a Windows orcad context owns calling it. + */ +export async function installOrcadWindowsHostScript( + target: { conn: SshConnection; host: RemoteHostPlatform; signal?: AbortSignal }, + baseDir: string +): Promise { + await target.conn.writeFile( + orcadWindowsHostScriptPath(target.host, baseDir), + ORCAD_WINDOWS_HOST_SCRIPT, + { hostPlatform: target.host, signal: target.signal } + ) +} + +/** The decoded payload after `marker`, or null when the host printed no such line. */ +export function readOrcadWindowsEncodedAnswer(output: string, marker: string): string | null { + const line = output + .split(/\r?\n/u) + .map((candidate) => candidate.trim()) + .findLast((candidate) => candidate === marker || candidate.startsWith(`${marker} `)) + if (line === undefined) { + return null + } + const encoded = line.slice(marker.length).trim() + if (!/^[A-Za-z0-9+/]*={0,2}$/u.test(encoded)) { + return null + } + return Buffer.from(encoded, 'base64').toString('utf8') +} diff --git a/src/main/ssh/orcad-remote-windows-scripts.test.ts b/src/main/ssh/orcad-remote-windows-scripts.test.ts new file mode 100644 index 000000000000..c34fa9f474ca --- /dev/null +++ b/src/main/ssh/orcad-remote-windows-scripts.test.ts @@ -0,0 +1,295 @@ +/** + * Runs the host script Windows orcad hosts execute, under this machine's node. + * + * They use only `fs`, `path` and `process.kill(pid, 0)`, whose ESRCH/EPERM split libuv gives on + * every platform. The process-tree addon is faked by a preload that loads `*.node` as a table of + * creation times, so the identity rules run for real without Windows. + */ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { ORCAD_WINDOWS_PROCESS_TREE_FILENAME } from '../../shared/orcad-artifacts' +import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' +import { ORCAD_WINDOWS_PROCESS_FILENAME } from './orcad-remote-host-support' +import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' +import { parseOrcadReadinessWaitOutput } from './orcad-remote-readiness-wait' +import { readOrcadWindowsEncodedAnswer } from './orcad-remote-windows-node' +import { + ORCAD_RECORD_ABSENT_MARKER, + ORCAD_RECORD_PRESENT_MARKER, + ORCAD_WINDOWS_HOST_SCRIPT, + ORCAD_WINDOWS_READINESS_MARKER, + ORCAD_WINDOWS_RUNTIME_MARKER, + type OrcadWindowsHostOp +} from './orcad-windows-host-script' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const FAKE_ADDON_PRELOAD = [ + 'const Module=require("module"),fs=require("fs");', + 'Module._extensions[".node"]=(m,f)=>{m.exports={getProcessCreationTime:(pid)=>{', + 'try{return JSON.parse(fs.readFileSync(f+".json","utf8"))[pid]}catch{return undefined}}}};' +].join('') +// Above any real PID on macOS and Linux defaults, so kill(pid, 0) is ESRCH. +const DEAD_PID = 4_194_303 + +let dir = '' +let preload = '' +let script = '' +const children: { kill: () => boolean }[] = [] + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orcad-win-scripts-')) + preload = join(dir, 'fake-addon-preload.js') + writeFileSync(preload, FAKE_ADDON_PRELOAD) + script = join(dir, 'orcad-host-script.js') + writeFileSync(script, ORCAD_WINDOWS_HOST_SCRIPT) +}) + +afterEach(() => { + for (const child of children.splice(0)) { + child.kill() + } + rmSync(dir, { recursive: true, force: true }) +}) + +async function runOp(op: OrcadWindowsHostOp, args: string[], withAddon = true) { + return runProcess({ + program: process.execPath, + args: [...(withAddon ? ['--require', preload] : []), script, op, ...args], + timeoutMs: 15_000 + }) +} + +function stageAddon(creationTimes: Record): void { + writeFileSync(join(dir, ORCAD_WINDOWS_PROCESS_TREE_FILENAME), '') + writeFileSync( + join(dir, `${ORCAD_WINDOWS_PROCESS_TREE_FILENAME}.json`), + JSON.stringify(creationTimes) + ) +} + +function recordProcess(pid: number, creationTimeMs: number | null): void { + writeFileSync(join(dir, ORCAD_WINDOWS_PROCESS_FILENAME), JSON.stringify({ pid, creationTimeMs })) +} + +function readyLine(health: Record): string { + return `${JSON.stringify({ type: 'orca_server_ready', runtimeId: 'r1', health })}\n` +} + +describe('Windows liveness script', () => { + const liveness = async (withAddon = true) => (await runOp('liveness', [dir], withAddon)).stdout + + it('is LIVE only when the PID runs and its creation time matches the record', async () => { + stageAddon({ [process.pid]: 1000 }) + recordProcess(process.pid, 1000) + expect(await liveness()).toBe('LIVE') + }) + + it('is DEAD for an exited PID and for a running PID with another creation time', async () => { + stageAddon({ [process.pid]: 2000 }) + recordProcess(DEAD_PID, 1000) + expect(await liveness()).toBe('DEAD') + recordProcess(process.pid, 1000) + expect(await liveness()).toBe('DEAD') + }) + + it('is UNKNOWN when nothing proves identity: no record, no time, no addon', async () => { + expect(await liveness()).toBe('UNKNOWN') + recordProcess(process.pid, null) + stageAddon({ [process.pid]: 1000 }) + expect(await liveness()).toBe('UNKNOWN') + recordProcess(process.pid, 1000) + expect(await liveness(false)).toBe('UNKNOWN') + stageAddon({}) + expect(await liveness()).toBe('UNKNOWN') + }) +}) + +describe('Windows stop script', () => { + const requestFile = () => join(dir, ORCAD_STOP_REQUEST_FILENAME) + const stop = async (justLaunched: boolean, waitSeconds = 5) => + (await runOp('stop', [dir, String(waitSeconds), justLaunched ? '1' : '0'])).stdout + + /** Stands in for orcad's stop-request listener: exits once the request file appears. */ + function fakeOrcad(): number { + const child = spawnProcess({ + program: process.execPath, + args: [ + '-e', + 'const fs=require("fs");setInterval(()=>{if(fs.existsSync(process.argv[1]))process.exit(0)},50)', + requestFile() + ] + }) + children.push(child) + if (!child.pid) { + throw new Error('fake orcad did not start') + } + return child.pid + } + + it('asks a capable build through the request file and waits for it to exit', async () => { + const pid = fakeOrcad() + stageAddon({ [pid]: 1000 }) + recordProcess(pid, 1000) + writeFileSync(join(dir, ORCAD_READINESS_FILENAME), readyLine({ pid, stopRequests: 1 })) + expect(await stop(false)).toBe('STOPPED') + }) + + it('writes the request for a just-launched candidate that has not published readiness', async () => { + const pid = fakeOrcad() + stageAddon({ [pid]: 1000 }) + recordProcess(pid, 1000) + expect(await stop(true)).toBe('STOPPED') + }) + + it('refuses a build that cannot be asked, without writing anything', async () => { + stageAddon({ [process.pid]: 1000 }) + recordProcess(process.pid, 1000) + writeFileSync(join(dir, ORCAD_READINESS_FILENAME), readyLine({ pid: process.pid })) + expect(await stop(false)).toBe('UNSUPPORTED') + expect(existsSync(requestFile())).toBe(false) + }) + + it('never addresses a PID the readiness record does not corroborate', async () => { + stageAddon({ [process.pid]: 1000 }) + recordProcess(process.pid, 1000) + writeFileSync(join(dir, ORCAD_READINESS_FILENAME), readyLine({ pid: 1, stopRequests: 1 })) + expect(await stop(false)).toBe('UNKNOWN') + expect(existsSync(requestFile())).toBe(false) + // A settled stop needs readiness unless this client just launched the slot. + writeFileSync(join(dir, ORCAD_READINESS_FILENAME), '') + expect(await stop(false)).toBe('UNKNOWN') + }) + + it('reports no record, an exited process, and one that outlives the wait', async () => { + expect(await stop(true)).toBe('NO_PID') + stageAddon({ [process.pid]: 1000 }) + recordProcess(DEAD_PID, 1000) + expect(await stop(true)).toBe('ALREADY_EXITED') + recordProcess(process.pid, 1000) + expect(await stop(true, 0)).toBe('STILL_RUNNING') + expect(existsSync(requestFile())).toBe(true) + }) +}) + +describe('Windows readiness wait script', () => { + const host = getRemoteHostPlatform('win32-x64') + const file = () => join(dir, ORCAD_READINESS_FILENAME) + const wait = async (seconds: number) => { + const { stdout } = await runOp( + 'readiness-wait', + [file(), String(256 * 1024), String(seconds)], + false + ) + return parseOrcadReadinessWaitOutput(host, stdout) + } + + it('answers as soon as a line is complete, byte-exact through base64', async () => { + const line = readyLine({ pid: 7, stopRequests: 1, dataDir: 'C:/Users/Zoë/.orca' }) + setTimeout(() => writeFileSync(file(), line), 300) + const started = Date.now() + const result = await wait(10) + expect(Date.now() - started).toBeLessThan(8_000) + expect(result).toMatchObject({ state: 'ready', readiness: { runtimeId: 'r1' } }) + expect(result.state === 'ready' && result.readiness.health).toMatchObject({ + dataDir: 'C:/Users/Zoë/.orca' + }) + }) + + it('is still pending when its bounded wait ends on a partial line', async () => { + writeFileSync(file(), '{"type":"orca_ser') + expect(await wait(1)).toEqual({ state: 'pending' }) + }) + + it('reads a missing file as nothing yet', async () => { + expect(await wait(0)).toEqual({ state: 'pending' }) + }) +}) + +describe('Windows record scripts', () => { + const read = (path: string, max = 1024) => runOp('record-read', [path, String(max)], false) + + it('tells absent from present, and returns the bytes exactly', async () => { + const path = join(dir, 'orcad-active.json') + expect((await read(path)).stdout.trim()).toBe(ORCAD_RECORD_ABSENT_MARKER) + writeFileSync(path, '{"active":"0.2.0+bb01","owner":"Zoë"}') + const present = await read(path) + expect(readOrcadWindowsEncodedAnswer(present.stdout, ORCAD_RECORD_PRESENT_MARKER)).toBe( + '{"active":"0.2.0+bb01","owner":"Zoë"}' + ) + }) + + it('refuses an oversized record or a directory rather than reading it as absent', async () => { + const path = join(dir, 'big.json') + writeFileSync(path, 'x'.repeat(2048)) + expect((await read(path)).code).toBe(65) + mkdirSync(join(dir, 'a-dir')) + expect((await read(join(dir, 'a-dir'))).code).toBe(65) + }) + + it('publishes a staged record over the existing one', async () => { + const path = join(dir, 'transaction.json') + const staged = `${path}.partial.1.abc` + writeFileSync(path, 'old') + writeFileSync(staged, 'new') + const result = await runOp('record-publish', [staged, path], false) + expect(result.code).toBe(0) + expect(readFileSync(path, 'utf8')).toBe('new') + expect(existsSync(staged)).toBe(false) + }) + + it('fails without a stage rather than publishing nothing', async () => { + const result = await runOp( + 'record-publish', + [join(dir, 'missing'), join(dir, 'transaction.json')], + false + ) + expect(result.code).not.toBe(0) + }) +}) + +describe('Windows slot runtime and file removal', () => { + const slot = () => join(dir, 'orcad-0.2.0+bb01') + const sha = 'a'.repeat(64) + + it('names the runtime the slot marker points at, and clears a stale stop request on launch', async () => { + mkdirSync(slot()) + writeFileSync(join(slot(), '.runtime-node'), `${sha}\n`) + writeFileSync(join(slot(), ORCAD_STOP_REQUEST_FILENAME), '') + expect((await runOp('slot-runtime', [slot()], false)).code).toBe(78) + mkdirSync(join(dir, 'runtimes', `node-${sha}`), { recursive: true }) + writeFileSync(join(dir, 'runtimes', `node-${sha}`, 'node.exe'), '') + const plain = await runOp('slot-runtime', [slot()], false) + expect(readOrcadWindowsEncodedAnswer(plain.stdout, ORCAD_WINDOWS_RUNTIME_MARKER)).toBe( + join(dir, 'runtimes', `node-${sha}`, 'node.exe') + ) + expect(existsSync(join(slot(), ORCAD_STOP_REQUEST_FILENAME))).toBe(true) + await runOp('slot-runtime', [slot(), 'clear-stop-request'], false) + expect(existsSync(join(slot(), ORCAD_STOP_REQUEST_FILENAME))).toBe(false) + }) + + it('refuses a marker that is not a bare sha256', async () => { + mkdirSync(slot()) + writeFileSync(join(slot(), '.runtime-node'), '../../escape') + expect((await runOp('slot-runtime', [slot()], false)).code).toBe(78) + }) + + it('removes a file and treats an absent one as removed', async () => { + const file = join(dir, 'staged.json') + writeFileSync(file, '{}') + expect((await runOp('remove-file', [file], false)).code).toBe(0) + expect(existsSync(file)).toBe(false) + expect((await runOp('remove-file', [file], false)).code).toBe(0) + }) + + it('rejects an unknown op', async () => { + const result = await runProcess({ program: process.execPath, args: [script, 'nope'] }) + expect(result.code).toBe(64) + }) +}) + +it('decodes nothing from output that lacks the marker', () => { + expect(readOrcadWindowsEncodedAnswer('noise\r\n', ORCAD_WINDOWS_READINESS_MARKER)).toBeNull() +}) diff --git a/src/main/ssh/orcad-windows-host-script.ts b/src/main/ssh/orcad-windows-host-script.ts new file mode 100644 index 000000000000..4fc41cfadcca --- /dev/null +++ b/src/main/ssh/orcad-windows-host-script.ts @@ -0,0 +1,217 @@ +/** + * The one fixed script Windows orcad hosts run, as a file beside the slots, under the pinned + * node.exe with plain path and number arguments: `node.exe