From 9292d18f65e913bcccc8ac499a3d511ebac00cf6 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 02:43:45 -0700 Subject: [PATCH] feat(orcad): deploy, activate and roll back orcad on Windows SSH hosts (W3) (#24563) * feat(orcad): deploy, activate and roll back orcad on Windows SSH hosts (W3) * test(orcad): exhaustive op switch in the Windows lifecycle fake * test(ssh): narrow the Windows host-cell descriptor by lane before building a relay cell --------- Co-authored-by: m4air --- .github/workflows/ssh-windows-hosts.yml | 3 +- .../invoke-pinned-relay-cells.ps1 | 10 +- .../preview-ssh/prove-preview-openssh.ps1 | 2 +- .../ssh-windows-hosts-workflow.test.mjs | 9 +- src/main/ssh/orcad-activation-lock.ts | 19 +- src/main/ssh/orcad-incumbent-recovery.ts | 17 +- .../ssh/orcad-initial-activation-admission.ts | 12 +- src/main/ssh/orcad-installed-activation.ts | 8 +- src/main/ssh/orcad-remote-context.ts | 9 +- src/main/ssh/orcad-remote-deploy.ts | 2 - src/main/ssh/orcad-remote-host-support.ts | 9 +- .../orcad-remote-lifecycle-windows.test.ts | 235 ++++++++++++++++++ src/main/ssh/orcad-remote-preflight.ts | 56 ++++- src/main/ssh/orcad-remote-primitives.test.ts | 24 +- src/main/ssh/orcad-remote-rollback.ts | 2 - src/main/ssh/orcad-remote-windows-node.ts | 6 + src/main/ssh/orcad-rollback-transition.ts | 22 +- src/main/ssh/orcad-state-snapshot-members.ts | 22 ++ src/main/ssh/orcad-state-snapshot.test.ts | 41 ++- src/main/ssh/orcad-state-snapshot.ts | 94 ++++--- src/main/ssh/orcad-windows-host-lane.test.ts | 112 +++++++++ .../ssh/orcad-windows-host-preparation.ts | 40 +++ src/main/ssh/orcad-windows-host-script.ts | 12 + .../ssh/orcad-windows-host-state-ops.test.ts | 123 +++++++++ src/main/ssh/orcad-windows-host-state-ops.ts | 196 +++++++++++++++ .../ssh/ssh-relay-windows-host-lane.test.ts | 4 + src/main/ssh/ssh-windows-host-cells.test.ts | 7 + src/main/ssh/ssh-windows-host-cells.ts | 20 +- 28 files changed, 1034 insertions(+), 82 deletions(-) create mode 100644 src/main/ssh/orcad-remote-lifecycle-windows.test.ts create mode 100644 src/main/ssh/orcad-state-snapshot-members.ts create mode 100644 src/main/ssh/orcad-windows-host-lane.test.ts create mode 100644 src/main/ssh/orcad-windows-host-preparation.ts create mode 100644 src/main/ssh/orcad-windows-host-state-ops.test.ts create mode 100644 src/main/ssh/orcad-windows-host-state-ops.ts diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index b495032ae55..db06d560e80 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -33,6 +33,7 @@ on: - 'src/shared/windows-breakaway-launch*.ts' - '!src/**/*.test.ts' - 'src/main/ssh/ssh-relay-windows-host-lane.test.ts' + - 'src/main/ssh/orcad-windows-host-lane.test.ts' - 'config/ci/windows-ssh-provider/**' - '.github/workflows/ssh-windows-hosts.yml' workflow_dispatch: @@ -118,7 +119,7 @@ jobs: $receipts=Join-Path $pwd '.build/ssh-windows-host-receipts' New-Item -ItemType Directory -Force -Path $receipts | Out-Null $cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_}) - if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out')} + if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')} $archive='' if('${{ matrix.server }}' -eq 'preview'){ $archive=Join-Path $env:RUNNER_TEMP 'preview-${{ matrix.archive }}' diff --git a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 index e4bfae52d69..91ef23cc6da 100644 --- a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 +++ b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 @@ -6,11 +6,11 @@ param( [Parameter(Mandatory=$true)][hashtable]$Context, [Parameter(Mandatory=$true)][ValidateSet('win32-arm64','win32-x64')][string]$Target, [Parameter(Mandatory=$true)][string]$ReceiptRoot, - [ValidateSet('pinned-cmd','pinned-powershell','legacy-opt-out')][string[]]$Cells=@('pinned-cmd','pinned-powershell','legacy-opt-out') + [ValidateSet('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')][string[]]$Cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell') ) $ErrorActionPreference='Stop' if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1'){throw 'Disposable CI only'} -$shells=@{'pinned-cmd'='cmd';'pinned-powershell'='powershell';'legacy-opt-out'='cmd'} +$shells=@{'pinned-cmd'='cmd';'pinned-powershell'='powershell';'legacy-opt-out'='cmd';'orcad-cmd'='cmd';'orcad-powershell'='powershell'} if($Context.accounts.Count -lt $Cells.Count){throw 'Each cell needs its own private account'} if(-not $Context.forbiddenToolLog){throw 'Run the provisioning with -HiddenTools so toolchain calls are logged'} $openSshKey='HKLM:\SOFTWARE\OpenSSH' @@ -78,11 +78,13 @@ try { @{cell=$cell;target=$Target;host='127.0.0.1';port=[int]$Context.port;username=$account.name;identityFile=$Context.identityFile;home=$account.home;forbiddenToolLog=$Context.forbiddenToolLog;receipt=(Join-Path $ReceiptRoot "$cell.json")} | ConvertTo-Json | Set-Content -LiteralPath $descriptor -Encoding utf8NoBOM $env:ORCA_RUN_SSH_WINDOWS_HOST='1';$env:ORCA_SSH_WINDOWS_HOST_CELL=$descriptor Write-Host "Windows host cell $cell ($Target, DefaultShell $shell, account $($account.name))" - & node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/ssh/ssh-relay-windows-host-lane.test.ts --reporter=verbose 2>&1 | Tee-Object -FilePath (Join-Path $ReceiptRoot "$cell.log") + # orcad cells deploy managed orcad instead of the relay; same account and descriptor shape. + $lane=if($cell.StartsWith('orcad-')){'src/main/ssh/orcad-windows-host-lane.test.ts'}else{'src/main/ssh/ssh-relay-windows-host-lane.test.ts'} + & node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts $lane --reporter=verbose 2>&1 | Tee-Object -FilePath (Join-Path $ReceiptRoot "$cell.log") # Why global: under the workflow's GetNewClosure callback, bare $LASTEXITCODE reads a stale captured copy. $code=$global:LASTEXITCODE # The relay's own log is the only record of why it closed a client. - foreach($log in @(Get-ChildItem -Path (Join-Path $account.home '.orca-remote\relay-*\relay*.log') -File -ErrorAction SilentlyContinue)){Copy-Item -LiteralPath $log.FullName -Destination (Join-Path $ReceiptRoot "$cell.$($log.Directory.Name).$($log.Name)")} + foreach($log in @(Get-ChildItem -Path (Join-Path $account.home '.orca-remote\relay-*\relay*.log'),(Join-Path $account.home '.orca-remote\orcad-*\orcad.log') -File -ErrorAction SilentlyContinue)){Copy-Item -LiteralPath $log.FullName -Destination (Join-Path $ReceiptRoot "$cell.$($log.Directory.Name).$($log.Name)")} if(Test-Path -LiteralPath $Context.forbiddenToolLog){Copy-Item -LiteralPath $Context.forbiddenToolLog -Destination (Join-Path $ReceiptRoot "$cell.forbidden-tool-calls.log")} $summary.Add(@{cell=$cell;shell=$shell;account=$account.name;exitCode=$code}) if($code -ne 0){$failed.Add($cell)} diff --git a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 index 115345cf8a0..e61b1315a42 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 @@ -2,7 +2,7 @@ # -HiddenTools: the private accounts are denied every machine PATH directory holding one of these # executables, and their own PATH carries logging shims for them, so SSH sessions have no host toolchain. # -HostCellProbe receives a context hashtable (accounts, port, keys, shim log) once provisioning passes. -param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,4)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe) +param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,5)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe) $ErrorActionPreference = 'Stop' $target=@{arm64=@{os='Arm64';folder='OpenSSH-ARM64';machine='0xAA64';archive='698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42'};x64=@{os='X64';folder='OpenSSH-Win64';machine='0x8664';archive='23f50f3458c4c5d0b12217c6a5ddfde0137210a30fa870e98b29827f7b43aba5'}}[$Arch] $scopeServer=if($Server -eq 'inbox'){'Windows inbox OpenSSH.Server capability binaries'}else{'Microsoft Win32-OpenSSH 10.0.0.0p2-Preview'} diff --git a/config/scripts/ssh-windows-hosts-workflow.test.mjs b/config/scripts/ssh-windows-hosts-workflow.test.mjs index c137b268c0d..93f3c40e768 100644 --- a/config/scripts/ssh-windows-hosts-workflow.test.mjs +++ b/config/scripts/ssh-windows-hosts-workflow.test.mjs @@ -4,7 +4,8 @@ import { describe, expect, it } from 'vitest' import { parse } from 'yaml' import { WINDOWS_FORBIDDEN_TOOLS, - WINDOWS_HOST_CELL_IDS + WINDOWS_HOST_CELL_IDS, + WINDOWS_ORCAD_CELL_IDS } from '../../src/main/ssh/ssh-windows-host-cells.ts' const projectDir = resolve(import.meta.dirname, '../..') @@ -70,14 +71,16 @@ describe('SSH Windows-host workflow', () => { it('defaults to every cell the TypeScript lane knows', () => { const defaults = /\{\$cells=@\(([^)]*)\)\}/.exec(runStep.run)?.[1] expect(defaults?.split(',').map((id) => id.trim().replaceAll("'", ''))).toEqual([ - ...WINDOWS_HOST_CELL_IDS + ...WINDOWS_HOST_CELL_IDS, + ...WINDOWS_ORCAD_CELL_IDS ]) const invoker = readFileSync( join(projectDir, 'config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1'), 'utf8' ) - for (const id of WINDOWS_HOST_CELL_IDS) { + for (const id of [...WINDOWS_HOST_CELL_IDS, ...WINDOWS_ORCAD_CELL_IDS]) { expect(invoker).toContain(`'${id}'`) } + expect(invoker).toContain('src/main/ssh/orcad-windows-host-lane.test.ts') }) }) diff --git a/src/main/ssh/orcad-activation-lock.ts b/src/main/ssh/orcad-activation-lock.ts index 7a0a5e9a6e0..87de702f7ae 100644 --- a/src/main/ssh/orcad-activation-lock.ts +++ b/src/main/ssh/orcad-activation-lock.ts @@ -15,7 +15,8 @@ import { acquireInstallLock, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' import { RELAY_REMOTE_DIR } from './relay-protocol' import { removeRemoteFileCommand, removeRemoteTreeCommand } from './ssh-remote-commands' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { orcadRemoteBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { ORCAD_ACTIVATION_TRANSACTION_DIRNAME, ORCAD_ACTIVATION_TRANSACTION_FILENAME @@ -147,8 +148,22 @@ function releaseActivationFence( // Journal first: a release cut short must leave a lock without a journal, never the reverse. const journal = joinRemotePath(options.host, lockRoot, ORCAD_ACTIVATION_TRANSACTION_FILENAME) // Why no signal: a cancelled run must still be able to drop a fence it proved unnecessary. + const target = withoutAbortSignal(options) + if (isWindowsRemoteHost(options.host)) { + // `&&` does not parse under a PowerShell DefaultShell, so the two steps are two execs. + const baseDir = orcadRemoteBaseDir(options.host, options.remoteHome) + return execOrcadRemote( + target, + orcadWindowsHostOpCommand(options.host, baseDir, 'remove-file', [journal]) + ).then(() => + execOrcadRemote( + target, + orcadWindowsHostOpCommand(options.host, baseDir, 'remove-tree', [lockRoot]) + ).then(() => undefined) + ) + } return execOrcadRemote( - withoutAbortSignal(options), + target, `${removeRemoteFileCommand(options.host, journal)} && ${removeRemoteTreeCommand(options.host, lockRoot)}` ).then(() => undefined) } diff --git a/src/main/ssh/orcad-incumbent-recovery.ts b/src/main/ssh/orcad-incumbent-recovery.ts index 5e68778c1a3..487ec03e4d3 100644 --- a/src/main/ssh/orcad-incumbent-recovery.ts +++ b/src/main/ssh/orcad-incumbent-recovery.ts @@ -6,6 +6,7 @@ * when the change provably carries no terminals — the slot exposed RPC, so a pre-launch census * cannot vouch for it. */ +import { orcadRemoteBaseDir } from './orcad-remote-windows-node' import type { ServeReadiness } from '../server/serve-readiness' import { RELAY_REMOTE_DIR } from './relay-protocol' import { ORCAD_STATE_SNAPSHOT_DIR } from './orcad-activation-record' @@ -98,7 +99,12 @@ async function decideChangedStateRestore( // Read-only, so a lost answer is just "changed". const comparison = await execOrcadRemote( options, - compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) + compareOrcadStateSnapshotCommand( + options.host, + options.userDataDir, + snapshotDir, + orcadRemoteBaseDir(options.host, options.remoteHome) + ) ).catch(() => '') if (orcadSnapshotIsUnchanged(comparison)) { return 'unchanged' @@ -143,9 +149,14 @@ async function restoreState(options: OrcadSlotOptions, state: OrcadSnapshotVerdi ? restoreOrcadStateSnapshotCommand( options.host, options.userDataDir, - orcadSnapshotPath(options, state.dirName) + orcadSnapshotPath(options, state.dirName), + orcadRemoteBaseDir(options.host, options.remoteHome) + ) + : clearOrcadStateSnapshotMembersCommand( + options.host, + options.userDataDir, + orcadRemoteBaseDir(options.host, options.remoteHome) ) - : clearOrcadStateSnapshotMembersCommand(options.host, options.userDataDir) const restored = parseOrcadSnapshotRestore(await execOrcadRemote(options, command)) if (restored !== 'restored') { throw new Error(`The prelaunch state could not be restored (${restored}).`) diff --git a/src/main/ssh/orcad-initial-activation-admission.ts b/src/main/ssh/orcad-initial-activation-admission.ts index cfa85da4dab..abf94751853 100644 --- a/src/main/ssh/orcad-initial-activation-admission.ts +++ b/src/main/ssh/orcad-initial-activation-admission.ts @@ -9,7 +9,8 @@ import { ORCAD_LOCK_FILE_NAME } from '../orcad/orcad-instance-lock' import { PRIMARY_RUNTIME_METADATA_FILE } from '../../shared/runtime-bootstrap' import { shellEscape } from './ssh-connection-utils' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { orcadWindowsBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node' const OWNER_RECORD_MAX_BYTES = 64 * 1024 @@ -24,6 +25,15 @@ export function initialOrcadActivationAdmissionCommand( remoteInstallDir: string, legacyNodePath: string ): string { + if (isWindowsRemoteHost(host)) { + // Windows has no O_NOFOLLOW; the host script refuses links by lstat instead. + return orcadWindowsHostOpCommand( + host, + orcadWindowsBaseDir(host, remoteInstallDir), + 'owner-admission', + [userDataDir] + ) + } const owners = [ORCAD_LOCK_FILE_NAME, PRIMARY_RUNTIME_METADATA_FILE].map((name) => ({ name, path: joinRemotePath(host, userDataDir, name) diff --git a/src/main/ssh/orcad-installed-activation.ts b/src/main/ssh/orcad-installed-activation.ts index d3960508052..429b5d9519f 100644 --- a/src/main/ssh/orcad-installed-activation.ts +++ b/src/main/ssh/orcad-installed-activation.ts @@ -5,6 +5,7 @@ * the host for `recoverInterruptedOrcadActivation` to finish or undo it. A run that ends with * the host provably back on one slot drops the fence; one that cannot prove it keeps it. */ +import { orcadRemoteBaseDir } from './orcad-remote-windows-node' import { randomUUID } from 'node:crypto' import type { OrcadDeployOptions, OrcadDeployResult } from './orcad-remote-deploy' import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' @@ -176,7 +177,12 @@ export async function activateInstalledOrcad( const capture = parseOrcadSnapshotCapture( await execOrcadRemote( options, - captureOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) + captureOrcadStateSnapshotCommand( + options.host, + options.userDataDir, + snapshotDir, + orcadRemoteBaseDir(options.host, options.remoteHome) + ) ).catch((error: unknown) => { if (isUnconfirmedSshCommandTermination(error)) { throw error diff --git a/src/main/ssh/orcad-remote-context.ts b/src/main/ssh/orcad-remote-context.ts index 6b893ba2950..d328cfbcc75 100644 --- a/src/main/ssh/orcad-remote-context.ts +++ b/src/main/ssh/orcad-remote-context.ts @@ -4,11 +4,12 @@ import type { SshTarget } from '../../shared/ssh-types' import type { OrcadActivationRecord } from './orcad-activation-record' import { readOrcadActivationRecord } from './orcad-activation-record-store' import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' -import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { prepareWindowsOrcadHost } from './orcad-windows-host-preparation' import { execOrcadRemote } from './orcad-remote-runtime-control' import type { SshConnection } from './ssh-connection' import { readRemoteHomeCommand } from './ssh-remote-commands' import { + isWindowsRemoteHost, joinRemotePath, normalizeRemoteHome, validateRemoteHome, @@ -35,8 +36,6 @@ export async function resolveOrcadRemoteContext( if (!host) { throw new Error('This SSH host platform is not supported by managed orcad.') } - // Why first: every lifecycle step after this is POSIX-only, so refuse before probing further. - assertPosixOrcadHost(host) const remote = { conn: connection, host, signal } const remoteHome = normalizeRemoteHome( await execOrcadRemote(remote, readRemoteHomeCommand(host)), @@ -46,6 +45,10 @@ export async function resolveOrcadRemoteContext( throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) } const serverTarget = await resolveOrcadDeploymentTarget({ conn: connection, host, signal }) + if (isWindowsRemoteHost(host)) { + // Every Windows host op, the activation record read included, runs on the pinned node.exe. + await prepareWindowsOrcadHost({ conn: connection, host, remoteHome, serverTarget, signal }) + } const activationRecord = await readOrcadActivationRecord({ ...remote, remoteHome }) return { activationRecord, diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 0e74a106a38..cc5a22b3287 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -14,7 +14,6 @@ import { readOrcadActivationRecord } from './orcad-activation-record-store' import type { OrcadActivationVerdict } from './orcad-activation-gate' import type { OrcadTerminalCensus } from './orcad-update-plan' import type { RemoteHostPlatform } from './ssh-remote-platform' -import { assertPosixOrcadHost } from './orcad-remote-host-support' import { installOrcadBundle } from './orcad-remote-install' import { getAppEnvironment } from '../../shared/app-environment' import type { ServerTarget } from '../../shared/node-runtime-pin' @@ -62,7 +61,6 @@ export type OrcadDeployResult = /** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */ export async function deployOrcad(input: OrcadDeployOptions): Promise { - assertPosixOrcadHost(input.host) const target = input.target ?? (input.localOrcadDir diff --git a/src/main/ssh/orcad-remote-host-support.ts b/src/main/ssh/orcad-remote-host-support.ts index cd00d71cc52..90206fa5eab 100644 --- a/src/main/ssh/orcad-remote-host-support.ts +++ b/src/main/ssh/orcad-remote-host-support.ts @@ -3,9 +3,8 @@ * discovered at runtime. * * The install transaction is host-agnostic — it is the relay's, and the relay runs on - * 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. + * Windows. Deploy, rollback and their recovery run on Windows through the host script + * (`orcad-windows-host-script.ts`); managed stop, decommission and GC do not yet, and refuse. */ import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' @@ -14,8 +13,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 state snapshots, preflight, rollback and managed stop are ' + - 'POSIX-only. Use the relay for this host.' + 'host-agnostic, but managed stop, decommission and version GC are POSIX-only. ' + + 'Use the relay for this host.' ) this.name = 'OrcadRemoteLaunchUnsupportedError' } diff --git a/src/main/ssh/orcad-remote-lifecycle-windows.test.ts b/src/main/ssh/orcad-remote-lifecycle-windows.test.ts new file mode 100644 index 00000000000..1592c0b0d9a --- /dev/null +++ b/src/main/ssh/orcad-remote-lifecycle-windows.test.ts @@ -0,0 +1,235 @@ +/** + * The deploy and rollback drivers end to end against a Windows host whose every host op is answered by a + * fake: no POSIX command, no `-EncodedCommand` and no PowerShell hop on the orcad path. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RecordFile from './orcad-remote-record-file' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + isUnconfirmedSshCommandTermination: () => false +})) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) +vi.mock('./ssh-relay-install-transfers', () => ({ + uploadRelayDirectory: vi.fn().mockResolvedValue(undefined), + writeRelayFile: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ + ...(await importOriginal()), + writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./orcad-remote-node-runtime', () => ({ + ensureRemoteOrcadNodeRuntime: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./orcad-local-build-hash', () => ({ + computeLocalOrcadBuildHash: () => 'abc123def4567890' +})) +vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ + ...(await importOriginal>()), + readLocalFullVersion: () => '0.2.0+bb0100000000', + isRemoteInstallComplete: vi.fn().mockResolvedValue(false), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined) +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy' +import { rollbackOrcad } from './orcad-remote-rollback' +import { emptyOrcadActivationRecord } from './orcad-activation-record' +import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' + +const mockExec = vi.mocked(execCommand) +const host = getRemoteHostPlatform('win32-x64') +const VERSION = '0.2.0+bb0100000000' +const SLOT_NODE = 'C:\\Users\\u\\.orca-remote\\runtimes\\node-ab\\node.exe' + +const encoded = (marker: string, value: string): string => + `${marker} ${Buffer.from(value).toString('base64')}\r\n` + +const TARGET = '0.1.0+aa01' + +function readyLine(version = VERSION): string { + return `${JSON.stringify({ + type: 'orca_server_ready', + runtimeId: 'r1', + boundEndpoint: 'ws://127.0.0.1:7777', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled', + pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, + health: { + buildHash: 'abc123def4567890', + buildVersion: version, + nodeVersion: NODE_RUNTIME_PIN.version, + nodeAbi: '137', + platform: 'win32', + arch: 'x64', + pid: 4242, + stopRequests: 1, + terminalDaemon: { + state: 'live', + ownsFreshSessions: true, + pid: 2, + buildVersion: version, + entryPath: 'C:/x/daemon-entry.js', + protocolVersion: 3, + selfTest: { ok: true, coverage: 'handshake', verdict: 'healthy', durationMs: 5 } + } + } + })}\n` +} + +function scriptWindowsHost(log: string[], activeRecord: string | null = null): void { + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + log.push(text) + const op = /\.js ([a-z-]+)(?: |$)/u.exec(text)?.[1] ?? '' + switch (op) { + case 'record-read': + return activeRecord && text.includes('orcad-active.json') + ? encoded('__ORCAD_RECORD_PRESENT__', activeRecord) + : '__ORCAD_RECORD_ABSENT__\r\n' + case 'build-hash': + return '__ORCAD_BUILD_HASH__ abc123def4567890\r\n' + case 'owner-admission': + return 'CLEAR' + case 'slot-runtime': + return encoded('__ORCAD_RUNTIME__', SLOT_NODE) + case 'readiness-wait': + return encoded('__ORCAD_READINESS__', readyLine(text.includes(TARGET) ? TARGET : VERSION)) + case 'stop': + return 'STOPPED' + case 'snapshot-probe': + return 'PRESENT' + case 'snapshot-restore': + return 'RESTORED' + case 'state-newest-mtime': + return 'UNKNOWN' + case 'snapshot-capture': + return 'CAPTURED' + case 'remove-file': + case 'remove-tree': + return '' + default: + break + } + if (text.includes('--orcad-profile-state-preflight')) { + return JSON.stringify({ + type: 'orca_profile_state_ready', + nonce: text.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/)?.[0], + runtime: 'node', + runtimeVersion: NODE_RUNTIME_PIN.version, + sqliteVersion: '3.51.0', + artifactVersion: VERSION, + revision: 1 + }) + } + if (text.includes('--windows-breakaway-launch')) { + return 'ORCA_ORCAD_LAUNCH {"method":"breakaway","pid":4242,"inJob":false}\r\n' + } + // The relay's shared install fence is the one pre-existing PowerShell site left on this path. + if (text.startsWith('powershell.exe ')) { + return 'OPEN' + } + throw new Error(`unexpected Windows command: ${text}`) + }) +} + +function options(): OrcadDeployOptions { + return { + conn: Object.assign(Object.create(null), { writeFile: vi.fn().mockResolvedValue(undefined) }), + host, + remoteHome: 'C:/Users/u', + localOrcadDir: '/local/out/orcad', + target: 'win32-x64', + nodePath: 'C:/host/node.exe', + userDataDir: 'C:/Users/u/.orca', + bindHost: '127.0.0.1', + port: 7777, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + readinessTimeoutMs: 1_000, + sleep: async () => {}, + now: () => new Date('2026-10-02T00:00:00.000Z') + } +} + +beforeEach(() => { + mockExec.mockReset() + vi.mocked(writeAtomicOrcadRemoteRecord).mockClear() +}) + +describe('deployOrcad on a Windows host', () => { + it('activates a first install through node.exe host ops only', async () => { + const log: string[] = [] + scriptWindowsHost(log) + const result = await deployOrcad(options()) + expect(result).toMatchObject({ outcome: 'installed-and-activated', fullVersion: VERSION }) + const orcadOps = log.filter((command) => !command.startsWith('powershell.exe ')) + expect(orcadOps.length).toBeGreaterThan(0) + for (const command of orcadOps) { + expect(command).not.toMatch(/EncodedCommand|nohup|kill |head -c|tar |\bsh -c\b/u) + } + const ops = orcadOps.map((command) => /\.js ([a-z-]+)/u.exec(command)?.[1] ?? command) + expect(ops).toContain('owner-admission') + expect(ops).toContain('snapshot-capture') + expect(ops.indexOf('slot-runtime')).toBeLessThan( + ops.findIndex((entry) => entry.includes('--windows-breakaway-launch')) + ) + expect(ops).toContain('readiness-wait') + const record = vi + .mocked(writeAtomicOrcadRemoteRecord) + .mock.calls.find(([, path]) => path.endsWith('orcad-active.json')) + expect(JSON.parse(record?.[2] ?? '{}')).toMatchObject({ active: VERSION }) + }) + + it('rolls back by stop request, directory snapshot and node.exe launch', async () => { + const log: string[] = [] + const record = { + ...emptyOrcadActivationRecord(), + active: VERSION, + previous: TARGET, + activatedAt: '2026-10-01T00:00:00.000Z', + snapshot: { + dirName: `pre-${VERSION}-1000`, + takenBeforeVersion: VERSION, + readableByVersion: TARGET, + takenAt: '2026-10-01T00:00:00.000Z' + } + } + scriptWindowsHost(log, JSON.stringify(record)) + const { localOrcadDir: _dir, target: _target, force: _force, ...base } = options() + const result = await rollbackOrcad({ + ...base, + record, + targetBuildHash: 'abc123def4567890', + targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] } + }) + expect(result).toMatchObject({ outcome: 'rolled-back', target: TARGET }) + const ops = log.map((command) => /\.js ([a-z-]+)/u.exec(command)?.[1] ?? command) + // Stop before any state is touched; the target starts only after its state is back. + expect(ops.indexOf('stop')).toBeLessThan(ops.indexOf('snapshot-restore')) + expect(ops).toEqual([ + 'record-read', + 'record-read', + 'snapshot-probe', + 'state-newest-mtime', + 'build-hash', + 'stop', + 'snapshot-capture', + 'snapshot-restore', + 'slot-runtime', + '--windows-breakaway-launch', + 'readiness-wait', + 'record-read', + 'remove-file', + 'remove-tree' + ]) + for (const command of log) { + expect(command).not.toMatch(/EncodedCommand|kill |tar |nohup/u) + } + }) +}) diff --git a/src/main/ssh/orcad-remote-preflight.ts b/src/main/ssh/orcad-remote-preflight.ts index 40a17bcdde0..85e41339422 100644 --- a/src/main/ssh/orcad-remote-preflight.ts +++ b/src/main/ssh/orcad-remote-preflight.ts @@ -7,10 +7,17 @@ import { parseOrcadProfilePreflight } from '../../shared/orcad-profile-preflight' import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { + orcadWindowsBaseDir, + orcadWindowsHostOpCommand, + orcadWindowsNodeCommandLine, + readOrcadWindowsEncodedAnswer +} from './orcad-remote-windows-node' +import { ORCAD_WINDOWS_RUNTIME_MARKER } from './orcad-windows-host-script' import { orcadNodeSlotRuntimeCommand } from './orcad-remote-runtime' import { execCommand } from './ssh-relay-deploy-helpers' import { shellEscape } from './ssh-connection-utils' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' export function orcadProfilePreflightCommand( @@ -40,10 +47,47 @@ export async function preflightInstalledOrcad(options: { signal?: AbortSignal }): Promise { const nonce = randomUUID() - const output = await execCommand( - options.conn, - orcadProfilePreflightCommand(options.host, options.remoteInstallDir, nonce), - { signal: options.signal, timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS } - ) + const command = isWindowsRemoteHost(options.host) + ? await windowsOrcadProfilePreflightCommand(options, nonce) + : orcadProfilePreflightCommand(options.host, options.remoteInstallDir, nonce) + const output = await execCommand(options.conn, command, { + signal: options.signal, + timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, + wrapCommand: !isWindowsRemoteHost(options.host) + }) parseOrcadProfilePreflight(output, nonce, ORCAD_NODE_RUNTIME_IDENTITY, options.fullVersion) } + +/** + * Windows: resolve the slot's node.exe, then run its `orcad.js` with plain argv. No + * ORCA_BACKGROUND_LAUNCH here: orcad opens no window, and argv cannot set environment. + */ +async function windowsOrcadProfilePreflightCommand( + options: { + conn: SshConnection + host: RemoteHostPlatform + remoteInstallDir: string + signal?: AbortSignal + }, + nonce: string +): Promise { + const { host, remoteInstallDir } = options + const runtime = readOrcadWindowsEncodedAnswer( + await execCommand( + options.conn, + orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, remoteInstallDir), 'slot-runtime', [ + remoteInstallDir + ]), + { signal: options.signal, wrapCommand: false } + ), + ORCAD_WINDOWS_RUNTIME_MARKER + ) + if (!runtime) { + throw new Error('The Windows host did not name the runtime this orcad slot needs.') + } + return orcadWindowsNodeCommandLine(runtime, [ + joinRemotePath(host, remoteInstallDir, 'orcad.js'), + ORCAD_PROFILE_PREFLIGHT_FLAG, + nonce + ]) +} diff --git a/src/main/ssh/orcad-remote-primitives.test.ts b/src/main/ssh/orcad-remote-primitives.test.ts index bc8e1bcf021..8cc38a43eb0 100644 --- a/src/main/ssh/orcad-remote-primitives.test.ts +++ b/src/main/ssh/orcad-remote-primitives.test.ts @@ -7,9 +7,11 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ })) vi.mock('./ssh-remote-platform-detection', () => ({ detectRemoteHostPlatform: vi.fn() })) +vi.mock('./orcad-windows-host-preparation', () => ({ prepareWindowsOrcadHost: vi.fn() })) import { execCommand } from './ssh-relay-deploy-helpers' import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' +import { prepareWindowsOrcadHost } from './orcad-windows-host-preparation' import { resolveOrcadRemoteContext } from './orcad-remote-context' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' @@ -234,10 +236,26 @@ describe('readiness parsing bounds', () => { }) describe('orcad remote context', () => { - it('refuses a Windows host before probing anything else', async () => { + it('prepares a Windows host (runtime, host script) before reading the activation record', async () => { vi.mocked(detectRemoteHostPlatform).mockResolvedValueOnce(windows) + const order: string[] = [] + vi.mocked(prepareWindowsOrcadHost).mockImplementationOnce(async () => { + order.push('prepare') + }) + mockExec.mockImplementation(async (_conn, command: string) => { + if (command.includes('record-read')) { + order.push('record') + return '__ORCAD_RECORD_ABSENT__\r\n' + } + return 'C:\\Users\\u\r\n' + }) const sshTarget = { id: 't', label: 't', host: 'h', port: 22, username: 'u' } - await expect(resolveOrcadRemoteContext(sshTarget, conn)).rejects.toThrow() - expect(mockExec).not.toHaveBeenCalled() + const context = await resolveOrcadRemoteContext(sshTarget, conn) + expect(context).toMatchObject({ serverTarget: 'win32-x64', remoteHome: 'C:/Users/u' }) + expect(vi.mocked(prepareWindowsOrcadHost).mock.calls[0]?.[0]).toMatchObject({ + remoteHome: 'C:/Users/u', + serverTarget: 'win32-x64' + }) + expect(order).toEqual(['prepare', 'record']) }) }) diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts index 4640a6079bc..e6ad9424efc 100644 --- a/src/main/ssh/orcad-remote-rollback.ts +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -22,7 +22,6 @@ import { readOrcadActivationRecord } from './orcad-activation-record-store' import { sameOrcadActivationRecord } from './orcad-activation-transaction' import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' import type { RemoteHostPlatform } from './ssh-remote-platform' -import { assertPosixOrcadHost } from './orcad-remote-host-support' import { resolveOrcadActivationReadinessTimeout, withOrcadActivationLock @@ -58,7 +57,6 @@ export type OrcadRollbackResult = | { outcome: 'failed'; code: string; reason: string } export async function rollbackOrcad(input: OrcadRollbackOptions): Promise { - assertPosixOrcadHost(input.host) const options = { ...input, readinessTimeoutMs: resolveOrcadActivationReadinessTimeout( diff --git a/src/main/ssh/orcad-remote-windows-node.ts b/src/main/ssh/orcad-remote-windows-node.ts index c1913f06734..159559e54ab 100644 --- a/src/main/ssh/orcad-remote-windows-node.ts +++ b/src/main/ssh/orcad-remote-windows-node.ts @@ -16,6 +16,7 @@ import { } from '../../shared/orcad-artifacts' import { pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../shared/node-runtime-pin' import type { SshConnection } from './ssh-connection' +import { RELAY_REMOTE_DIR } from './relay-protocol' import { joinRemotePath, remoteDirname, type RemoteHostPlatform } from './ssh-remote-platform' import { powerShellLiteral } from './ssh-remote-powershell' import { @@ -73,6 +74,11 @@ export function orcadWindowsNodeCommandLine(executable: string, args: readonly s return `powershell.exe -NoProfile -NonInteractive -Command "${inner}"` } +/** `~/.orca-remote` from the remote home. */ +export function orcadRemoteBaseDir(host: RemoteHostPlatform, remoteHome: string): string { + return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) +} + /** `~/.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) diff --git a/src/main/ssh/orcad-rollback-transition.ts b/src/main/ssh/orcad-rollback-transition.ts index ce5fe39f34b..96e08cfb2f1 100644 --- a/src/main/ssh/orcad-rollback-transition.ts +++ b/src/main/ssh/orcad-rollback-transition.ts @@ -1,4 +1,5 @@ /** The locked, journaled half of `rollbackOrcad`. */ +import { orcadRemoteBaseDir } from './orcad-remote-windows-node' import { randomUUID } from 'node:crypto' import type { OrcadRollbackOptions, OrcadRollbackResult } from './orcad-remote-rollback' import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' @@ -58,7 +59,14 @@ async function stateWritesSinceActivation(options: OrcadRollbackOptions): Promis return null } const newest = parseNewestStateMtimeSeconds( - await execOrEmpty(options, newestStateMtimeCommand(options.host, options.userDataDir)) + await execOrEmpty( + options, + newestStateMtimeCommand( + options.host, + options.userDataDir, + orcadRemoteBaseDir(options.host, options.remoteHome) + ) + ) ) return newest === null ? null : newest >= activatedAtSeconds } @@ -73,7 +81,11 @@ export async function rollbackOrcadLocked( ? parseOrcadSnapshotPresence( await execOrEmpty( options, - probeOrcadStateSnapshotCommand(options.host, orcadSnapshotPath(options, snapshot.dirName)) + probeOrcadStateSnapshotCommand( + options.host, + orcadSnapshotPath(options, snapshot.dirName), + orcadRemoteBaseDir(options.host, options.remoteHome) + ) ) ) : 'absent' @@ -145,7 +157,8 @@ export async function rollbackOrcadLocked( captureOrcadStateSnapshotCommand( options.host, options.userDataDir, - orcadSnapshotPath(options, transaction.rescue.dirName) + orcadSnapshotPath(options, transaction.rescue.dirName), + orcadRemoteBaseDir(options.host, options.remoteHome) ) ) ) @@ -169,7 +182,8 @@ export async function rollbackOrcadLocked( restoreOrcadStateSnapshotCommand( options.host, options.userDataDir, - orcadSnapshotPath(options, snapshot.dirName) + orcadSnapshotPath(options, snapshot.dirName), + orcadRemoteBaseDir(options.host, options.remoteHome) ) ) ) diff --git a/src/main/ssh/orcad-state-snapshot-members.ts b/src/main/ssh/orcad-state-snapshot-members.ts new file mode 100644 index 00000000000..52b09ad2270 --- /dev/null +++ b/src/main/ssh/orcad-state-snapshot-members.ts @@ -0,0 +1,22 @@ +/** What the pre-activation snapshot holds; shared by the POSIX commands and the Windows host script. */ + +/** + * Root-relative paths a rollback needs restored. Everything else under the data root is + * either regenerable, or owned by a process that survives the rollback. + */ +export const ORCAD_SNAPSHOT_MEMBERS = [ + 'orca-profile-index.json', + // Pre-profiles layout; still read as a migration source. + 'orca-data.json', + 'profiles', + // Cross-profile SQLite moves must survive an orcad rollback too. + 'profile-move-intents' +] as const + +/** Never captured and never restored — see `orcad-state-snapshot.ts`. */ +export const ORCAD_SNAPSHOT_EXCLUDED = ['daemon', 'logs'] as const + +export const ORCAD_STATE_RESTORE_STAGE_DIRNAME = '.orcad-state-restore-stage' + +/** Windows keeps the snapshot as a directory copy, where POSIX keeps `state.tar`. */ +export const ORCAD_WINDOWS_SNAPSHOT_STATE_DIRNAME = 'state' diff --git a/src/main/ssh/orcad-state-snapshot.test.ts b/src/main/ssh/orcad-state-snapshot.test.ts index c8740507698..3838c1d8ff8 100644 --- a/src/main/ssh/orcad-state-snapshot.test.ts +++ b/src/main/ssh/orcad-state-snapshot.test.ts @@ -105,14 +105,39 @@ describe('detecting writes since activation', () => { }) describe('Windows hosts', () => { + const BASE = 'C:/Users/u/.orca-remote' it.each([ - ['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)], - ['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)], - ['clear', () => clearOrcadStateSnapshotMembersCommand(windows, ROOT)], - ['presence', () => probeOrcadStateSnapshotCommand(windows, SNAP)], - ['compare', () => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP)], - ['mtime', () => newestStateMtimeCommand(windows, ROOT)] - ])('refuses %s rather than emitting a POSIX command', (_label, build) => { - expect(build).toThrow('orcad to a Windows host is not implemented') + [ + 'capture', + 'snapshot-capture', + (base?: string) => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP, base) + ], + [ + 'restore', + 'snapshot-restore', + (base?: string) => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP, base) + ], + [ + 'clear', + 'snapshot-clear', + (base?: string) => clearOrcadStateSnapshotMembersCommand(windows, ROOT, base) + ], + [ + 'presence', + 'snapshot-probe', + (base?: string) => probeOrcadStateSnapshotCommand(windows, SNAP, base) + ], + [ + 'compare', + 'snapshot-compare', + (base?: string) => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP, base) + ], + ['mtime', 'state-newest-mtime', (base?: string) => newestStateMtimeCommand(windows, ROOT, base)] + ])('%s runs the host script op %s with node.exe, never a POSIX command', (_label, op, build) => { + const command = build(BASE) + expect(command).toContain(` ${op} `) + expect(command).toMatch(/^C:\\Users\\u\\\.orca-remote\\runtimes\\node-[0-9a-f]+\\node\.exe /u) + expect(command).not.toMatch(/tar |find |diff |EncodedCommand|powershell/u) + expect(() => build()).toThrow('~/.orca-remote') }) }) diff --git a/src/main/ssh/orcad-state-snapshot.ts b/src/main/ssh/orcad-state-snapshot.ts index a5ce56d7339..91b050d5da8 100644 --- a/src/main/ssh/orcad-state-snapshot.ts +++ b/src/main/ssh/orcad-state-snapshot.ts @@ -16,24 +16,15 @@ * massacre the daemon exists to prevent. */ import { shellEscape } from './ssh-connection-utils' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { assertPosixOrcadHost as assertPosixHost } from './orcad-remote-host-support' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import type { OrcadWindowsHostStateOp } from './orcad-windows-host-state-ops' +import { + ORCAD_SNAPSHOT_MEMBERS, + ORCAD_STATE_RESTORE_STAGE_DIRNAME +} from './orcad-state-snapshot-members' +import { orcadWindowsHostOpCommand } from './orcad-remote-windows-node' -/** - * Root-relative paths a rollback needs restored. Everything else under the data root is - * either regenerable, or owned by a process that survives the rollback. - */ -export const ORCAD_SNAPSHOT_MEMBERS = [ - 'orca-profile-index.json', - // Pre-profiles layout; still read as a migration source. - 'orca-data.json', - 'profiles', - // Cross-profile SQLite moves must survive an orcad rollback too. - 'profile-move-intents' -] as const - -/** Never captured and never restored — see the module comment. */ -export const ORCAD_SNAPSHOT_EXCLUDED = ['daemon', 'logs'] as const +export { ORCAD_SNAPSHOT_EXCLUDED, ORCAD_SNAPSHOT_MEMBERS } from './orcad-state-snapshot-members' /** * The member names go into the command unquoted (see `captureOrcadStateSnapshotCommand`), so @@ -47,7 +38,23 @@ function assertPlainMemberName(member: string): string { return member } -const RESTORE_STAGE_DIRNAME = '.orcad-state-restore-stage' +const RESTORE_STAGE_DIRNAME = ORCAD_STATE_RESTORE_STAGE_DIRNAME + +/** The Windows host-script op, or null on POSIX; `baseDir` is `~/.orca-remote`. */ +function windowsStateCommand( + host: RemoteHostPlatform, + baseDir: string | undefined, + op: OrcadWindowsHostStateOp, + args: string[] +): string | null { + if (!isWindowsRemoteHost(host)) { + return null + } + if (!baseDir) { + throw new Error('Windows orcad state commands need the ~/.orca-remote directory') + } + return orcadWindowsHostOpCommand(host, baseDir, op, args) +} function noSymlinkedStateCommand(path: string): string { return `links=$(find ${path} -type l -print) && [ -z "$links" ]` @@ -75,9 +82,13 @@ export function orcadRollbackRescueDirName(fullVersion: string, takenAtMs: numbe export function captureOrcadStateSnapshotCommand( host: RemoteHostPlatform, userDataDir: string, - snapshotDir: string + snapshotDir: string, + baseDir?: string ): string { - assertPosixHost(host) + const windows = windowsStateCommand(host, baseDir, 'snapshot-capture', [userDataDir, snapshotDir]) + if (windows) { + return windows + } const root = shellEscape(userDataDir) const dir = shellEscape(snapshotDir) const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) @@ -118,9 +129,13 @@ export function parseOrcadSnapshotCapture(output: string): OrcadSnapshotCapture export function probeOrcadStateSnapshotCommand( host: RemoteHostPlatform, - snapshotDir: string + snapshotDir: string, + baseDir?: string ): string { - assertPosixHost(host) + const windows = windowsStateCommand(host, baseDir, 'snapshot-probe', [snapshotDir]) + if (windows) { + return windows + } const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) return `test -f ${archive} && echo PRESENT || echo ABSENT` } @@ -151,9 +166,13 @@ export function parseOrcadSnapshotPresence(output: string): OrcadSnapshotPresenc export function restoreOrcadStateSnapshotCommand( host: RemoteHostPlatform, userDataDir: string, - snapshotDir: string + snapshotDir: string, + baseDir?: string ): string { - assertPosixHost(host) + const windows = windowsStateCommand(host, baseDir, 'snapshot-restore', [userDataDir, snapshotDir]) + if (windows) { + return windows + } const root = shellEscape(userDataDir) const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) const stage = shellEscape(joinRemotePath(host, userDataDir, RESTORE_STAGE_DIRNAME)) @@ -183,9 +202,13 @@ export function restoreOrcadStateSnapshotCommand( /** Restore an originally empty state root after a candidate populated it. */ export function clearOrcadStateSnapshotMembersCommand( host: RemoteHostPlatform, - userDataDir: string + userDataDir: string, + baseDir?: string ): string { - assertPosixHost(host) + const windows = windowsStateCommand(host, baseDir, 'snapshot-clear', [userDataDir]) + if (windows) { + return windows + } const root = shellEscape(userDataDir) const removals = ORCAD_SNAPSHOT_MEMBERS.map( (member) => `rm -rf ${root}/${shellEscape(member)}` @@ -207,9 +230,13 @@ export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore export function compareOrcadStateSnapshotCommand( host: RemoteHostPlatform, userDataDir: string, - snapshotDir: string + snapshotDir: string, + baseDir?: string ): string { - assertPosixHost(host) + const windows = windowsStateCommand(host, baseDir, 'snapshot-compare', [userDataDir, snapshotDir]) + if (windows) { + return windows + } const root = shellEscape(userDataDir) const dir = shellEscape(snapshotDir) const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) @@ -246,8 +273,15 @@ export function orcadSnapshotIsUnchanged(output: string): boolean { * caller compares; an `UNKNOWN` becomes `null`, which `assessOrcadRollback` treats as "yes, * assume writes". */ -export function newestStateMtimeCommand(host: RemoteHostPlatform, userDataDir: string): string { - assertPosixHost(host) +export function newestStateMtimeCommand( + host: RemoteHostPlatform, + userDataDir: string, + baseDir?: string +): string { + const windows = windowsStateCommand(host, baseDir, 'state-newest-mtime', [userDataDir]) + if (windows) { + return windows + } const root = shellEscape(userDataDir) const paths = ORCAD_SNAPSHOT_MEMBERS.map((member) => `${root}/${shellEscape(member)}`).join(' ') return [ diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts new file mode 100644 index 00000000000..bac21721fa2 --- /dev/null +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -0,0 +1,112 @@ +// Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: resolve the context +// (pinned node.exe, host script), deploy and activate, prove readiness and liveness, stop through +// the slot's request file, and prove exit. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 +// provisions the account and runs this file for `orcad-*` cells; ssh-windows-hosts.yml runs that. +// +// Run: ORCA_RUN_SSH_WINDOWS_HOST=1 ORCA_SSH_WINDOWS_HOST_CELL= pnpm test +import { randomUUID } from 'node:crypto' +import { writeFileSync } from 'node:fs' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) + +import { SshConnection } from './ssh-connection' +import { + connectHostileHost, + installHostileHostAppEnvironment +} from './ssh-hostile-host-test-harness' +import { resolveOrcadRemoteContext } from './orcad-remote-context' +import { deployOrcad } from './orcad-remote-deploy' +import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' +import { orcadSlotDir, stopOrcadSlot, type OrcadSlotOptions } from './orcad-recovery-slot' +import { execOrcadRemote } from './orcad-remote-runtime-control' +import { + isWindowsOrcadCellId, + readWindowsHostCellDescriptor, + windowsHostSshTarget +} from './ssh-windows-host-cells' + +const RUN = process.env.ORCA_RUN_SSH_WINDOWS_HOST === '1' +const CELL_TIMEOUT_MS = 20 * 60_000 + +/** Orcad's own host ops and launches: these must never take a PowerShell hop. */ +function isOrcadHostCommand(command: string): boolean { + return /orcad-host-script-[0-9a-f]{16}\.js|[\\/]orcad\.js /u.test(command) +} + +describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { + let cleanupAppEnvironment: (() => void) | null = null + + beforeAll(() => { + cleanupAppEnvironment = installHostileHostAppEnvironment() + }) + + afterAll(() => { + cleanupAppEnvironment?.() + }) + + it( + 'deploys, serves, stops by request and exits', + async () => { + const descriptor = readWindowsHostCellDescriptor(process.env.ORCA_SSH_WINDOWS_HOST_CELL ?? '') + if (!isWindowsOrcadCellId(descriptor.cell)) { + throw new Error(`${descriptor.cell} runs in ssh-relay-windows-host-lane.test.ts`) + } + const sshTarget = windowsHostSshTarget( + descriptor, + { id: descriptor.cell, remoteRuntime: 'pinned-node' }, + randomUUID() + ) + const exec = vi.spyOn(SshConnection.prototype, 'exec') + const receipt: Record = { cell: descriptor.cell, target: descriptor.target } + let conn: SshConnection | null = null + try { + conn = await connectHostileHost(sshTarget) + const context = await resolveOrcadRemoteContext(sshTarget, conn) + expect(context.host.os).toBe('win32') + const options: OrcadSlotOptions = { + conn, + host: context.host, + remoteHome: context.remoteHome, + nodePath: 'node', + userDataDir: context.userDataDir, + bindHost: '127.0.0.1', + port: 0 + } + const deployed = await deployOrcad({ + ...options, + target: context.serverTarget, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null } + }) + receipt.deploy = deployed + expect(deployed.outcome).toBe('installed-and-activated') + const slotDir = orcadSlotDir(options, deployed.fullVersion) + const liveness = async () => + parseOrcadLiveness( + await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, slotDir)) + ) + expect(await liveness()).toBe('LIVE') + receipt.stop = await stopOrcadSlot(options, slotDir, false) + expect(receipt.stop).toBe('stopped') + expect(await liveness()).toBe('DEAD') + + const commands = exec.mock.calls.map(([command]) => String(command)) + const orcadCommands = commands.filter(isOrcadHostCommand) + receipt.orcadCommands = orcadCommands.length + receipt.powershellCommands = commands.filter((command) => + /^powershell\.exe /iu.test(command) + ).length + expect(orcadCommands.length).toBeGreaterThan(0) + for (const command of orcadCommands) { + expect(command).not.toMatch(/EncodedCommand/u) + } + receipt.passed = true + } finally { + exec.mockRestore() + await conn?.disconnect().catch(() => {}) + writeFileSync(descriptor.receipt, `${JSON.stringify(receipt, null, 2)}\n`) + } + }, + CELL_TIMEOUT_MS + ) +}) diff --git a/src/main/ssh/orcad-windows-host-preparation.ts b/src/main/ssh/orcad-windows-host-preparation.ts new file mode 100644 index 00000000000..55b6d7531a5 --- /dev/null +++ b/src/main/ssh/orcad-windows-host-preparation.ts @@ -0,0 +1,40 @@ +/** + * What every managed-orcad operation on a Windows host needs before its first host op: this + * client's pinned node.exe in the runtime store, and the host script it runs. + * + * Both are idempotent: a present runtime costs one probe and nothing is uploaded, and the host + * script is content-addressed, so rewriting it changes nothing a running orcad depends on. + */ +import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import type { ServerTarget } from '../../shared/node-runtime-pin' +import { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime' +import { installOrcadWindowsHostScript, orcadRemoteBaseDir } from './orcad-remote-windows-node' +import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' +import type { SshConnection } from './ssh-connection' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +export async function prepareWindowsOrcadHost(options: { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + serverTarget: ServerTarget + signal?: AbortSignal +}): Promise { + const baseDir = orcadRemoteBaseDir(options.host, options.remoteHome) + await ensureRemoteOrcadNodeRuntime({ + conn: options.conn, + host: options.host, + // Only its parent is read: the runtime store sits beside the slots. + slotDir: joinRemotePath(options.host, baseDir, 'orcad-host'), + target: options.serverTarget, + archivePath: () => + materializeNodeRuntimeArchive( + options.serverTarget, + join(getAppEnvironment().getPath('userData'), 'orcad-artifacts'), + { signal: options.signal } + ), + signal: options.signal + }) + await installOrcadWindowsHostScript(options, baseDir) +} diff --git a/src/main/ssh/orcad-windows-host-script.ts b/src/main/ssh/orcad-windows-host-script.ts index 4fc41cfadcc..92f529f88bb 100644 --- a/src/main/ssh/orcad-windows-host-script.ts +++ b/src/main/ssh/orcad-windows-host-script.ts @@ -23,6 +23,10 @@ import { ORCAD_STOP_REQUEST_FILENAME, ORCAD_STOP_REQUESTS_CAPABILITY } from '../../shared/orcad-stop-request' +import { + ORCAD_WINDOWS_HOST_STATE_OPS, + type OrcadWindowsHostStateOp +} from './orcad-windows-host-state-ops' import { ORCAD_READINESS_FILENAME, ORCAD_READINESS_MAX_BYTES, @@ -48,6 +52,8 @@ export type OrcadWindowsHostOp = | 'record-publish' | 'slot-runtime' | 'remove-file' + | 'remove-tree' + | OrcadWindowsHostStateOp const text = JSON.stringify @@ -187,6 +193,11 @@ const ops = { answer('') }, + 'remove-tree'(target) { + fs.rmSync(target, { recursive: true, force: true, maxRetries: 5 }) + answer('') + }, + // The node.exe a slot's marker names; with clear-stop-request, also drops a stale request. 'slot-runtime'(slotDir, mode) { let sha @@ -202,6 +213,7 @@ const ops = { } } +${ORCAD_WINDOWS_HOST_STATE_OPS} const run = Object.hasOwn(ops, op) ? ops[op] : null if (!run) { process.stderr.write('unknown orcad host op: ' + String(op) + '\\n') diff --git a/src/main/ssh/orcad-windows-host-state-ops.test.ts b/src/main/ssh/orcad-windows-host-state-ops.test.ts new file mode 100644 index 00000000000..00e979c1529 --- /dev/null +++ b/src/main/ssh/orcad-windows-host-state-ops.test.ts @@ -0,0 +1,123 @@ +/** + * Runs the Windows host script's state ops under this machine's node, against real files: + * the snapshot a rollback depends on, its comparison and restore, and owner admission. + */ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { ORCAD_WINDOWS_HOST_SCRIPT, type OrcadWindowsHostOp } from './orcad-windows-host-script' + +let dir = '' +let root = '' +let snapshot = '' +let script = '' + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orcad-win-state-')) + root = join(dir, '.orca') + snapshot = join(dir, '.orca-remote', 'orcad-snapshots', 'pre-0.2.0+bb01-1') + script = join(dir, 'orcad-host-script.js') + writeFileSync(script, ORCAD_WINDOWS_HOST_SCRIPT) + mkdirSync(join(root, 'profiles', 'p1'), { recursive: true }) + mkdirSync(join(root, 'daemon'), { recursive: true }) + writeFileSync(join(root, 'orca-profile-index.json'), '{"v":"before"}') + writeFileSync(join(root, 'profiles', 'p1', 'orca-data.json'), '{"repos":"before"}') + writeFileSync(join(root, 'daemon', 'daemon.sock.token'), 'live-daemon-token') +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +async function op(name: OrcadWindowsHostOp, ...args: string[]): Promise { + const result = await runProcess({ program: process.execPath, args: [script, name, ...args] }) + expect(result.code, result.stderr).toBe(0) + return result.stdout.trim() +} + +describe('Windows snapshot ops', () => { + it('captures, proves unchanged, then restores the members and never the daemon', async () => { + expect(await op('snapshot-probe', snapshot)).toBe('ABSENT') + expect(await op('snapshot-capture', root, snapshot)).toBe('CAPTURED') + expect(await op('snapshot-probe', snapshot)).toBe('PRESENT') + expect(await op('snapshot-compare', root, snapshot)).toBe('UNCHANGED') + + writeFileSync(join(root, 'profiles', 'p1', 'orca-data.json'), '{"repos":"after"}') + writeFileSync(join(root, 'profiles', 'p1', 'added.json'), '{}') + writeFileSync(join(root, 'daemon', 'daemon.sock.token'), 'rotated-token') + expect(await op('snapshot-compare', root, snapshot)).toBe('CHANGED') + + expect(await op('snapshot-restore', root, snapshot)).toBe('RESTORED') + expect(readFileSync(join(root, 'profiles', 'p1', 'orca-data.json'), 'utf8')).toBe( + '{"repos":"before"}' + ) + expect(existsSync(join(root, 'profiles', 'p1', 'added.json'))).toBe(false) + expect(readFileSync(join(root, 'daemon', 'daemon.sock.token'), 'utf8')).toBe('rotated-token') + expect(existsSync(join(root, '.orcad-state-restore-stage'))).toBe(false) + expect(await op('snapshot-compare', root, snapshot)).toBe('UNCHANGED') + }) + + it('reports EMPTY rather than fabricating a snapshot of a root with no state', async () => { + const empty = join(dir, 'empty-root') + mkdirSync(empty) + expect(await op('snapshot-capture', empty, snapshot)).toBe('EMPTY') + expect(await op('snapshot-probe', snapshot)).toBe('ABSENT') + }) + + it('fails closed on a link inside captured state', async () => { + symlinkSync(join(dir), join(root, 'profiles', 'escape'), 'junction') + expect(await op('snapshot-capture', root, snapshot)).toBe('FAILED') + expect(await op('snapshot-probe', snapshot)).toBe('ABSENT') + }) + + it('answers MISSING, UNKNOWN and FAILED rather than guessing', async () => { + expect(await op('snapshot-restore', root, snapshot)).toBe('MISSING') + expect(await op('snapshot-compare', root, snapshot)).toBe('UNKNOWN') + expect(await op('snapshot-compare', join(dir, 'no-root'), snapshot)).toBe('UNKNOWN') + }) + + it('clears the members of a root that started empty, leaving the daemon', async () => { + expect(await op('snapshot-clear', root)).toBe('RESTORED') + expect(existsSync(join(root, 'profiles'))).toBe(false) + expect(existsSync(join(root, 'orca-profile-index.json'))).toBe(false) + expect(existsSync(join(root, 'daemon', 'daemon.sock.token'))).toBe(true) + }) + + it('reports the newest member write in epoch seconds, or UNKNOWN', async () => { + const at = new Date('2026-09-30T12:00:00.000Z') + utimesSync(join(root, 'orca-profile-index.json'), at, at) + utimesSync(join(root, 'profiles', 'p1', 'orca-data.json'), at, at) + expect(await op('state-newest-mtime', root)).toBe(String(Math.floor(at.getTime() / 1000))) + const empty = join(dir, 'empty-root') + mkdirSync(empty) + expect(await op('state-newest-mtime', empty)).toBe('UNKNOWN') + }) +}) + +describe('Windows owner admission', () => { + it('is CLEAR with no owners, LIVE for a running owner, and silent for a dead one', async () => { + expect(await op('owner-admission', root)).toBe('CLEAR') + writeFileSync(join(root, 'orcad.lock'), JSON.stringify({ pid: process.pid })) + expect(await op('owner-admission', root)).toBe(`LIVE orcad.lock ${process.pid}`) + writeFileSync(join(root, 'orcad.lock'), JSON.stringify({ pid: 4_194_303 })) + expect(await op('owner-admission', root)).toBe('CLEAR') + }) + + it('refuses a record it cannot interpret', async () => { + writeFileSync(join(root, 'orcad.lock'), 'not json') + expect(await op('owner-admission', root)).toBe('UNVERIFIABLE orcad.lock') + writeFileSync(join(root, 'orcad.lock'), JSON.stringify({ pid: -1 })) + expect(await op('owner-admission', root)).toBe('UNVERIFIABLE orcad.lock') + }) +}) diff --git a/src/main/ssh/orcad-windows-host-state-ops.ts b/src/main/ssh/orcad-windows-host-state-ops.ts new file mode 100644 index 00000000000..9e6d85869e4 --- /dev/null +++ b/src/main/ssh/orcad-windows-host-state-ops.ts @@ -0,0 +1,196 @@ +/** + * The host script's state ops on Windows: the pre-activation snapshot, its comparison and + * restore, the newest-write probe, and the first-activation owner admission. + * + * Same contract and tokens as the POSIX commands in `orcad-state-snapshot.ts` and + * `orcad-initial-activation-admission.ts`. The snapshot is a directory copy (`state/`) rather + * than a tar: there is no tar in Node, and spawning `tar.exe` would be a second process. It is + * built under a partial name and renamed into place, so a half-written snapshot is never + * `PRESENT`. Symlinks and junctions anywhere in captured state fail closed, as on POSIX. + */ +import { PRIMARY_RUNTIME_METADATA_FILE } from '../../shared/runtime-bootstrap' +import { ORCAD_LOCK_FILE_NAME } from '../orcad/orcad-instance-lock' +import { + ORCAD_SNAPSHOT_MEMBERS, + ORCAD_STATE_RESTORE_STAGE_DIRNAME, + ORCAD_WINDOWS_SNAPSHOT_STATE_DIRNAME +} from './orcad-state-snapshot-members' + +export type OrcadWindowsHostStateOp = + | 'snapshot-capture' + | 'snapshot-probe' + | 'snapshot-restore' + | 'snapshot-clear' + | 'snapshot-compare' + | 'state-newest-mtime' + | 'owner-admission' + +const OWNER_RECORD_MAX_BYTES = 64 * 1024 +const text = JSON.stringify + +/** Evaluated inside the host script, after `fs`, `path`, `answer` and `ops` exist. */ +export const ORCAD_WINDOWS_HOST_STATE_OPS = ` +const MEMBERS = ${text(ORCAD_SNAPSHOT_MEMBERS)} +const STATE_DIR = ${text(ORCAD_WINDOWS_SNAPSHOT_STATE_DIRNAME)} +const RESTORE_STAGE = ${text(ORCAD_STATE_RESTORE_STAGE_DIRNAME)} + +function lstatOrNull(target) { + try { return fs.lstatSync(target) } catch (error) { if (error.code === 'ENOENT') return null; throw error } +} + +// Node reports junctions as symbolic links too, so one check covers both. +function treeHasLink(target) { + const stats = fs.lstatSync(target) + if (stats.isSymbolicLink()) return true + if (!stats.isDirectory()) return false + return fs.readdirSync(target).some((name) => treeHasLink(path.join(target, name))) +} + +function treesEqual(left, right) { + const a = lstatOrNull(left) + const b = lstatOrNull(right) + if (!a || !b) return !a && !b + if (a.isSymbolicLink() || b.isSymbolicLink()) throw new Error('link in state') + if (a.isDirectory() !== b.isDirectory()) return false + if (!a.isDirectory()) return a.size === b.size && fs.readFileSync(left).equals(fs.readFileSync(right)) + const names = fs.readdirSync(left).sort() + const other = fs.readdirSync(right).sort() + return names.length === other.length && names.every((name, index) => name === other[index] && treesEqual(path.join(left, name), path.join(right, name))) +} + +function newestMtime(target) { + const stats = lstatOrNull(target) + if (!stats || stats.isSymbolicLink()) return null + if (!stats.isDirectory()) return stats.mtimeMs + return fs.readdirSync(target).reduce((newest, name) => { + const value = newestMtime(path.join(target, name)) + return value === null || (newest !== null && newest >= value) ? newest : value + }, null) +} + +function renameWithRetry(from, to) { + for (const delay of [0, 50, 100, 150, 200, 250]) { + if (delay) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay) + try { fs.renameSync(from, to); return } catch (error) { + if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || delay === 250) throw error + } + } +} + +const removeTree = (target) => fs.rmSync(target, { recursive: true, force: true, maxRetries: 5 }) + +Object.assign(ops, { + 'snapshot-capture'(root, snapshotDir) { + let present + try { + present = MEMBERS.filter((member) => lstatOrNull(path.join(root, member))) + if (present.some((member) => treeHasLink(path.join(root, member)))) return answer('FAILED') + } catch { return answer('FAILED') } + if (present.length === 0) return answer('EMPTY') + const partial = path.join(snapshotDir, STATE_DIR + '.partial-' + process.pid) + try { + removeTree(partial) + fs.mkdirSync(partial, { recursive: true }) + for (const member of present) { + fs.cpSync(path.join(root, member), path.join(partial, member), { recursive: true, errorOnExist: true }) + } + removeTree(path.join(snapshotDir, STATE_DIR)) + renameWithRetry(partial, path.join(snapshotDir, STATE_DIR)) + } catch { + try { removeTree(partial) } catch {} + return answer('FAILED') + } + answer('CAPTURED') + }, + + 'snapshot-probe'(snapshotDir) { + let stats + try { stats = lstatOrNull(path.join(snapshotDir, STATE_DIR)) } catch { return answer('UNKNOWN') } + answer(stats && stats.isDirectory() ? 'PRESENT' : 'ABSENT') + }, + + // Copy into a stage first, so an unreadable snapshot fails before live state is touched. + 'snapshot-restore'(root, snapshotDir) { + const state = path.join(snapshotDir, STATE_DIR) + const stats = lstatOrNull(state) + if (!stats || !stats.isDirectory()) return answer('MISSING') + const stage = path.join(root, RESTORE_STAGE) + try { + fs.mkdirSync(root, { recursive: true }) + removeTree(stage) + fs.cpSync(state, stage, { recursive: true }) + if (!MEMBERS.some((member) => lstatOrNull(path.join(stage, member)))) { + removeTree(stage) + return answer('FAILED') + } + } catch { + try { removeTree(stage) } catch {} + return answer('FAILED') + } + try { + for (const member of MEMBERS) removeTree(path.join(root, member)) + for (const member of MEMBERS) { + if (lstatOrNull(path.join(stage, member))) renameWithRetry(path.join(stage, member), path.join(root, member)) + } + removeTree(stage) + } catch { return answer('FAILED') } + answer('RESTORED') + }, + + 'snapshot-clear'(root) { + try { + fs.mkdirSync(root, { recursive: true }) + for (const member of MEMBERS) removeTree(path.join(root, member)) + } catch { return answer('FAILED') } + answer('RESTORED') + }, + + 'snapshot-compare'(root, snapshotDir) { + try { + const rootStats = lstatOrNull(root) + const state = path.join(snapshotDir, STATE_DIR) + const stateStats = lstatOrNull(state) + if (!rootStats || !rootStats.isDirectory() || !stateStats || !stateStats.isDirectory()) return answer('UNKNOWN') + if (treeHasLink(state)) return answer('UNKNOWN') + for (const member of MEMBERS) { + const live = path.join(root, member) + if (lstatOrNull(live) && treeHasLink(live)) return answer('UNKNOWN') + if (!treesEqual(live, path.join(state, member))) return answer('CHANGED') + } + } catch { return answer('UNKNOWN') } + answer('UNCHANGED') + }, + + 'state-newest-mtime'(root) { + let newest = null + try { + for (const member of MEMBERS) { + const value = newestMtime(path.join(root, member)) + if (value !== null && (newest === null || value > newest)) newest = value + } + } catch { return answer('UNKNOWN') } + answer(newest === null ? 'UNKNOWN' : String(Math.floor(newest / 1000))) + }, + + // A live, unreadable or unexpected owner record defers the first activation. + 'owner-admission'(userDataDir) { + const owners = ${text([ORCAD_LOCK_FILE_NAME, PRIMARY_RUNTIME_METADATA_FILE])} + for (const name of owners) { + const file = path.join(userDataDir, name) + let pid + try { + const stats = lstatOrNull(file) + if (!stats) continue + if (!stats.isFile() || stats.size > ${OWNER_RECORD_MAX_BYTES}) return answer('UNVERIFIABLE ' + name) + pid = JSON.parse(fs.readFileSync(file, 'utf8')).pid + } catch { return answer('UNVERIFIABLE ' + name) } + if (!Number.isSafeInteger(pid) || pid <= 0) return answer('UNVERIFIABLE ' + name) + try { process.kill(pid, 0); return answer('LIVE ' + name + ' ' + pid) } catch (error) { + if (error.code === 'EPERM') return answer('LIVE ' + name + ' ' + pid) + if (error.code !== 'ESRCH') return answer('UNVERIFIABLE ' + name) + } + } + answer('CLEAR') + } +}) +` diff --git a/src/main/ssh/ssh-relay-windows-host-lane.test.ts b/src/main/ssh/ssh-relay-windows-host-lane.test.ts index 6d0a978593b..56068d55bf4 100644 --- a/src/main/ssh/ssh-relay-windows-host-lane.test.ts +++ b/src/main/ssh/ssh-relay-windows-host-lane.test.ts @@ -31,6 +31,7 @@ import { type WindowsSessionCommandAudit } from './ssh-session-command-audit' import { + isWindowsOrcadCellId, readWindowsHostCellDescriptor, windowsHostCell, windowsHostSshTarget @@ -54,6 +55,9 @@ describe.runIf(RUN)('SSH relay on a Windows OpenSSH host', () => { 'lands the cell the descriptor names', async () => { const descriptor = readWindowsHostCellDescriptor(process.env.ORCA_SSH_WINDOWS_HOST_CELL ?? '') + if (isWindowsOrcadCellId(descriptor.cell)) { + throw new Error(`${descriptor.cell} runs in orcad-windows-host-lane.test.ts`) + } const cell = windowsHostCell(descriptor.cell, descriptor.target) const observer = localHostObserver(descriptor.forbiddenToolLog) const sshTarget = windowsHostSshTarget(descriptor, cell, randomUUID()) diff --git a/src/main/ssh/ssh-windows-host-cells.test.ts b/src/main/ssh/ssh-windows-host-cells.test.ts index e061c840f2a..90d7f317a26 100644 --- a/src/main/ssh/ssh-windows-host-cells.test.ts +++ b/src/main/ssh/ssh-windows-host-cells.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + isWindowsOrcadCellId, parseWindowsHostCellDescriptor, WINDOWS_FORBIDDEN_TOOLS, WINDOWS_HOST_CELL_IDS, @@ -44,6 +45,9 @@ describe('Windows SSH-host cells', () => { it('reads the descriptor PowerShell writes, BOM included', () => { const parsed = parseWindowsHostCellDescriptor(`\uFEFF${JSON.stringify(DESCRIPTOR)}`) expect(parsed).toEqual(DESCRIPTOR) + if (isWindowsOrcadCellId(parsed.cell)) { + throw new Error(`expected a relay cell, got ${parsed.cell}`) + } const target = windowsHostSshTarget(parsed, windowsHostCell(parsed.cell, parsed.target), 'r1') expect(target).toMatchObject({ id: 'windows-host-pinned-powershell-r1', @@ -60,6 +64,9 @@ describe('Windows SSH-host cells', () => { const parse = (patch: Record): unknown => parseWindowsHostCellDescriptor(JSON.stringify({ ...DESCRIPTOR, ...patch })) expect(() => parse({ cell: 'pinned-bash' })).toThrow('Unknown Windows host cell') + expect( + parseWindowsHostCellDescriptor(JSON.stringify({ ...DESCRIPTOR, cell: 'orcad-cmd' })).cell + ).toBe('orcad-cmd') expect(() => parse({ target: 'linux-x64-glibc' })).toThrow('win32-x64 or win32-arm64') expect(() => parse({ port: '22' })).toThrow('port is invalid') expect(() => parse({ port: 70_000 })).toThrow('port is invalid') diff --git a/src/main/ssh/ssh-windows-host-cells.ts b/src/main/ssh/ssh-windows-host-cells.ts index 8e2d49f4fdf..58bddc1acc7 100644 --- a/src/main/ssh/ssh-windows-host-cells.ts +++ b/src/main/ssh/ssh-windows-host-cells.ts @@ -50,9 +50,21 @@ export function windowsHostCell( } } +/** Managed orcad on the pinned node.exe, per DefaultShell: deploy, readiness, stop request, exit. */ +export const WINDOWS_ORCAD_CELL_IDS = ['orcad-cmd', 'orcad-powershell'] as const +export type WindowsOrcadCellId = (typeof WINDOWS_ORCAD_CELL_IDS)[number] + +export function isWindowsOrcadCellId(id: string): id is WindowsOrcadCellId { + return WINDOWS_ORCAD_CELL_IDS.some((candidate) => candidate === id) +} + +export function windowsOrcadCellShell(id: WindowsOrcadCellId): WindowsSshDefaultShell { + return id === 'orcad-cmd' ? 'cmd' : 'powershell' +} + /** Written by config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1, one per run. */ export type WindowsHostCellDescriptor = { - cell: WindowsHostCellId + cell: WindowsHostCellId | WindowsOrcadCellId target: WindowsServerTarget host: string port: number @@ -78,7 +90,9 @@ export function parseWindowsHostCellDescriptor(text: string): WindowsHostCellDes throw new Error('Windows host cell descriptor must be a JSON object') } const record = Object.fromEntries(Object.entries(parsed)) - const cell = WINDOWS_HOST_CELL_IDS.find((id) => id === record.cell) + const cell = + WINDOWS_HOST_CELL_IDS.find((id) => id === record.cell) ?? + WINDOWS_ORCAD_CELL_IDS.find((id) => id === record.cell) if (!cell) { throw new Error(`Unknown Windows host cell: ${String(record.cell)}`) } @@ -109,7 +123,7 @@ export function readWindowsHostCellDescriptor(path: string): WindowsHostCellDesc export function windowsHostSshTarget( descriptor: WindowsHostCellDescriptor, - cell: WindowsHostCell, + cell: Pick, runId: string ): SshTarget { return {