diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 9cadb3a8b853..9da215290278 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -1,10 +1,9 @@ name: SSH Windows hosts -# Design D5/D6 exit gate, Windows half: the real client-side relay deploy against a real -# Win32-OpenSSH server on 127.0.0.1 (inbox capability and the pinned 10.0.0.0p2-Preview release), -# on x64 and arm64. Each job provisions a private sshd service and one standard account per cell -# (config/ci/windows-ssh-provider/), hides the host toolchain from SSH sessions behind logging -# shims, and asserts rung A on the pinned node.exe in both DefaultShell cases plus the opt-out. +# Managed orcad against a real Win32-OpenSSH server on 127.0.0.1 (inbox capability and the pinned +# 10.0.0.0p2-Preview release), on x64 and arm64. Each job provisions a private sshd service and one +# standard account per cell (config/ci/windows-ssh-provider/), hides the host toolchain from SSH +# sessions behind logging shims, and deploys orcad on the pinned node.exe in both DefaultShell cases. on: pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -30,13 +29,11 @@ on: - 'native/windows-cli-launcher/**' - 'src/main/orcad/orcad-cli-launcher.ts' - '.github/actions/prepare-windows-cli-launcher/**' - - 'config/scripts/build-relay.mjs' - 'config/patches/node-pty*' - 'config/patches/@vscode__windows-process-tree*' - 'config/scripts/build-windows-process-tree-relay-addon.mjs' - 'config/scripts/relay-windows-process-tree-prepared-addon*.mjs' - 'config/scripts/windows-process-tree-gyp-rebuild.mjs' - - 'src/shared/relay-windows-breakaway-launch.ts' - 'src/shared/windows-breakaway-launch*.ts' - 'src/main/ipc/ssh-host-server-*.ts' - '!src/**/*.test.ts' @@ -136,8 +133,8 @@ jobs: native-runtime: node cache-pnpm-store: 'false' # The deploy materializes rung A from this template; only this runner's slot exists here. - # The process-tree addon carries the launcher that starts the relay outside sshd's job; the - # orcad slot and the relay both stage it, and a standard-user host has no other launch route. + # The process-tree addon carries the launcher that starts orcad outside sshd's job; the orcad + # slot stages it, and a standard-user host has no other launch route. - name: Build this runner's Windows process-table addon shell: bash env: @@ -155,12 +152,11 @@ jobs: - uses: ./.github/actions/prepare-windows-cli-launcher with: arches: ${{ matrix.arch }} - - name: Build the win32 template and the relay + - name: Build the win32 template shell: bash run: | pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}" - pnpm run build:relay - wait: inbox-capability - name: Run the Windows host cells against a private ${{ matrix.server }} sshd shell: pwsh @@ -190,7 +186,7 @@ jobs: if($LASTEXITCODE -ne 0){throw 'SSH archive fetch failed'} } $callback={param($context) - & (Join-Path $tools 'invoke-pinned-relay-cells.ps1') -SourceRoot $sourceRoot -Context $context -Target 'win32-${{ matrix.arch }}' -ReceiptRoot $receipts -Cells $cells + & (Join-Path $tools 'invoke-windows-host-cells.ps1') -SourceRoot $sourceRoot -Context $context -Target 'win32-${{ matrix.arch }}' -ReceiptRoot $receipts -Cells $cells }.GetNewClosure() & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -InboxPreparationReceipt $preparation -Accounts $cells.Count -ForwardingAccounts $forwarding -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log') - uses: actions/upload-artifact@v7 diff --git a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 b/config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1 similarity index 85% rename from config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 rename to config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1 index d9ebbf1f19d4..13d18b02ae98 100644 --- a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 +++ b/config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1 @@ -6,13 +6,13 @@ 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','orcad-cmd','orcad-powershell','orcad-convert','orcad-cli-managed','orcad-cli-convert','orcad-cli-relay-kept')][string[]]$Cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell') + [ValidateSet('orcad-cmd','orcad-powershell','orcad-convert','orcad-cli-managed','orcad-cli-convert')][string[]]$Cells=@('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';'orcad-cmd'='cmd';'orcad-powershell'='powershell';'orcad-convert'='cmd';'orcad-cli-managed'='cmd';'orcad-cli-convert'='cmd';'orcad-cli-relay-kept'='cmd'} +$shells=@{'orcad-cmd'='cmd';'orcad-powershell'='powershell';'orcad-convert'='cmd';'orcad-cli-managed'='cmd';'orcad-cli-convert'='cmd'} # App-level cells drive the e2e build (and the bundled CLI) against the host; each greps one tagged test. -$appCells=@{'orcad-convert'=@('tests/e2e/ssh-orcad-auto-convert.spec.ts','');'orcad-cli-managed'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-managed');'orcad-cli-convert'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-convert');'orcad-cli-relay-kept'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-relay-kept')} +$appCells=@{'orcad-convert'=@('tests/e2e/ssh-orcad-auto-convert.spec.ts','');'orcad-cli-managed'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-managed');'orcad-cli-convert'=@('tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts','@orcad-cli-convert')} $electronBuilt=$false if($Context.accounts.Count -lt $Cells.Count){throw 'Each cell needs its own private account'} $seenApp=$false @@ -48,9 +48,9 @@ function Set-PrivateDefaultShell([string]$Shell) { } } -# Diagnostic only: Orca must launch the relay without WMI, which refuses a standard user's SSH +# Diagnostic only: Orca must launch orcad without WMI, which refuses a standard user's SSH # (network) logon unless an administrator grants Remote Enable on root\cimv2. The cells run with no -# such grant, so a relay launch that still needs WMI fails its cell. Prints ORCA_WMI= +# such grant, so a launch that still needs WMI fails its cell. Prints ORCA_WMI= # or ORCA_WMI=denied. function Test-PrivateWmiLaunch([string]$Account) { $out=Invoke-PrivateSsh $Account 'powershell.exe -NoProfile -NonInteractive -Command "try{$r=Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine=''cmd.exe /d /c exit 0''} -ErrorAction Stop;''ORCA_WMI=''+$r.ReturnValue}catch{''ORCA_WMI=denied''}"' @@ -99,7 +99,7 @@ try { $env:ORCA_BACKGROUND_LAUNCH='1' # DefaultShell is still stock cmd here. $wmi=Test-PrivateWmiLaunch $Context.accounts[0].name - Write-Host "Standard SSH user WMI Win32_Process.Create: $wmi (no grant; the relay launch must not depend on it)" + Write-Host "Standard SSH user WMI Win32_Process.Create: $wmi (no grant; the orcad launch must not depend on it)" $summary.Add(@{standardUserWmiLaunch=$wmi;granted=$false}) for($index=0;$index -lt $Cells.Count;$index++){ $cell=$Cells[$index];$account=$Context.accounts[$index];$shell=$shells[$cell] @@ -122,21 +122,20 @@ try { # 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. # orcad.log holds only the last launch on Windows; orcad.log.1 is the one before a restart. - foreach($log in @(Get-ChildItem -Path (Join-Path $account.home '.orca-remote\relay-*\relay*.log'),(Join-Path $account.home '.orca-remote\orcad-*\orcad.log'),(Join-Path $account.home '.orca-remote\orcad-*\orcad.log.1') -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\orcad-*\orcad.log'),(Join-Path $account.home '.orca-remote\orcad-*\orcad.log.1') -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)} } - # Relays outlive their client by a 60s grace; wait so cleanup can unload the profiles. + # Host processes can outlive their client; wait so cleanup can unload the profiles. $homes=@($Context.accounts | ForEach-Object {$_.home.TrimEnd('\')+'\'}) $graceDeadline=[DateTime]::UtcNow.AddSeconds(120) do { - $relays=@(Get-CimInstance Win32_Process | Where-Object {$path=$_.ExecutablePath;$path -and @($homes | Where-Object {$path.StartsWith($_,[StringComparison]::OrdinalIgnoreCase)}).Count}) - if(-not $relays.Count){break};Start-Sleep -Seconds 2 + $hostProcesses=@(Get-CimInstance Win32_Process | Where-Object {$path=$_.ExecutablePath;$path -and @($homes | Where-Object {$path.StartsWith($_,[StringComparison]::OrdinalIgnoreCase)}).Count}) + if(-not $hostProcesses.Count){break};Start-Sleep -Seconds 2 } while([DateTime]::UtcNow -lt $graceDeadline) - $summary.Add(@{relayProcessesAfterGrace=@($relays | ForEach-Object {[IO.Path]::GetFileName($_.ExecutablePath)})}) + $summary.Add(@{hostProcessesAfterGrace=@($hostProcesses | ForEach-Object {[IO.Path]::GetFileName($_.ExecutablePath)})}) } finally { Set-PrivateDefaultShell 'cmd' if($knownExisted){[IO.File]::WriteAllBytes($knownPath,$priorKnown)}else{Remove-Item -LiteralPath $knownPath -Force -ErrorAction SilentlyContinue} diff --git a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 index 5bd3ccf30cdc..088534f7be50 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 @@ -3,7 +3,7 @@ $errors=$null;$tokens=$null $ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'prove-preview-openssh.ps1'),[ref]$tokens,[ref]$errors) if($errors.Count){throw 'Fixture failed to parse'} # Script-scope assignments share one case-insensitive namespace with typed params: $accounts rebinds [int]$Accounts. -foreach($script in @($ast,[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../invoke-pinned-relay-cells.ps1'),[ref]$null,[ref]$null))){ +foreach($script in @($ast,[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../invoke-windows-host-cells.ps1'),[ref]$null,[ref]$null))){ $params=@($script.ParamBlock.Parameters | ForEach-Object {$_.Name.VariablePath.UserPath}) $shadows=@($script.FindAll({param($node) $node -is [Management.Automation.Language.AssignmentStatementAst] -and $node.Left -is [Management.Automation.Language.VariableExpressionAst]},$true) | Where-Object { $parent=$_.Parent;while($parent -and $parent -isnot [Management.Automation.Language.FunctionDefinitionAst] -and $parent -isnot [Management.Automation.Language.ScriptBlockExpressionAst]){$parent=$parent.Parent} diff --git a/src/main/ssh/orcad-windows-host-lane.test.ts b/src/main/ssh/orcad-windows-host-lane.test.ts index 3067816ae644..df02ed2fea0f 100644 --- a/src/main/ssh/orcad-windows-host-lane.test.ts +++ b/src/main/ssh/orcad-windows-host-lane.test.ts @@ -1,8 +1,9 @@ // 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, reach orcad -// through the stdio bridge (this account's sshd refuses forwarding), decommission -// through the instance-bound stop request, prove exit, and run a GC pass. 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. +// through the stdio bridge (this account's sshd refuses forwarding), decommission through the +// instance-bound stop request, prove exit, and run a GC pass. +// config/ci/windows-ssh-provider/invoke-windows-host-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' @@ -59,11 +60,7 @@ describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => { if (!isWindowsOrcadCellId(descriptor.cell)) { throw new Error(`${descriptor.cell} is not a managed orcad cell`) } - const sshTarget = windowsHostSshTarget( - descriptor, - { id: descriptor.cell, remoteRuntime: 'pinned-node' }, - randomUUID() - ) + const sshTarget = windowsHostSshTarget(descriptor, randomUUID()) let exec: MockInstance | null = null const receipt: Record = { cell: descriptor.cell, target: descriptor.target } let conn: SshConnection | null = null diff --git a/src/main/ssh/ssh-windows-host-cells.test.ts b/src/main/ssh/ssh-windows-host-cells.test.ts index f184b461ec95..9d135c5b27db 100644 --- a/src/main/ssh/ssh-windows-host-cells.test.ts +++ b/src/main/ssh/ssh-windows-host-cells.test.ts @@ -1,15 +1,8 @@ import { describe, expect, it } from 'vitest' -import { - isWindowsHostCellId, - parseWindowsHostCellDescriptor, - WINDOWS_FORBIDDEN_TOOLS, - WINDOWS_HOST_CELL_IDS, - windowsHostCell, - windowsHostSshTarget -} from './ssh-windows-host-cells' +import { parseWindowsHostCellDescriptor, windowsHostSshTarget } from './ssh-windows-host-cells' const DESCRIPTOR = { - cell: 'pinned-powershell', + cell: 'orcad-powershell', target: 'win32-arm64', host: '127.0.0.1', port: 50022, @@ -17,38 +10,15 @@ const DESCRIPTOR = { identityFile: 'C:\\ossh\\client_key', home: 'C:/Users/orcaabc', forbiddenToolLog: 'C:\\ossh\\forbidden.log', - receipt: 'C:\\receipts\\pinned-powershell.json' + receipt: 'C:\\receipts\\orcad-powershell.json' } describe('Windows SSH-host cells', () => { - it('pairs each relay cell with its DefaultShell and remote runtime', () => { - const cells = WINDOWS_HOST_CELL_IDS.map((id) => windowsHostCell(id)) - expect( - cells.map(({ id, defaultShell, remoteRuntime }) => [id, defaultShell, remoteRuntime]) - ).toEqual([ - ['pinned-cmd', 'cmd', 'pinned-node'], - ['pinned-powershell', 'powershell', 'pinned-node'], - ['legacy-opt-out', 'cmd', 'legacy'] - ]) - }) - - it('shims the Windows compilers alongside the Linux toolchain list', () => { - expect(WINDOWS_FORBIDDEN_TOOLS).toEqual( - expect.arrayContaining(['npm', 'node-gyp', 'cl', 'clang']) - ) - // Why never node: the lane proves host Node is absent from PATH, not merely logged. - expect(WINDOWS_FORBIDDEN_TOOLS).not.toContain('node') - }) - it('reads the descriptor PowerShell writes, BOM included', () => { const parsed = parseWindowsHostCellDescriptor(`\uFEFF${JSON.stringify(DESCRIPTOR)}`) expect(parsed).toEqual(DESCRIPTOR) - if (!isWindowsHostCellId(parsed.cell)) { - throw new Error(`expected a relay cell, got ${parsed.cell}`) - } - const target = windowsHostSshTarget(parsed, windowsHostCell(parsed.cell), 'r1') - expect(target).toMatchObject({ - id: 'windows-host-pinned-powershell-r1', + expect(windowsHostSshTarget(parsed, 'r1')).toMatchObject({ + id: 'windows-host-orcad-powershell-r1', host: '127.0.0.1', port: 50022, username: 'orcaabc', @@ -61,10 +31,13 @@ describe('Windows SSH-host cells', () => { it('rejects a descriptor that would drive the wrong host or cell', () => { 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({ cell: 'pinned-cmd' })).toThrow('Unknown Windows host cell') + expect(() => parse({ cell: 'orcad-cli-relay-kept' })).toThrow('Unknown Windows host cell') + for (const cell of ['orcad-cmd', 'orcad-convert', 'orcad-cli-managed', 'orcad-cli-convert']) { + expect(parseWindowsHostCellDescriptor(JSON.stringify({ ...DESCRIPTOR, cell })).cell).toBe( + cell + ) + } 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 9b6032dbb3df..45634e480c4e 100644 --- a/src/main/ssh/ssh-windows-host-cells.ts +++ b/src/main/ssh/ssh-windows-host-cells.ts @@ -1,62 +1,10 @@ /** - * Windows SSH-host lanes for the pinned relay (design D5/D6 exit gate): a real Win32-OpenSSH - * server on 127.0.0.1, provisioned by config/ci/windows-ssh-provider/, driven through the same - * harness as the Docker hostile-host matrix. `.github/workflows/ssh-windows-hosts.yml` runs it. + * Windows SSH-host cells for managed orcad: a real Win32-OpenSSH server on 127.0.0.1, provisioned + * by config/ci/windows-ssh-provider/. `.github/workflows/ssh-windows-hosts.yml` runs them. */ import { readFileSync } from 'node:fs' import { isWindowsServerTarget, type WindowsServerTarget } from '../../shared/node-runtime-pin' -import type { SshRemoteRuntime, SshTarget } from '../../shared/ssh-types' -import { FORBIDDEN_TOOLS } from './ssh-hostile-host-cells' - -/** `cmd` is stock sshd (no DefaultShell value); `powershell` sets DefaultShell to Windows PowerShell. */ -export const WINDOWS_SSH_DEFAULT_SHELLS = ['cmd', 'powershell'] as const -export type WindowsSshDefaultShell = (typeof WINDOWS_SSH_DEFAULT_SHELLS)[number] - -/** Shimmed on the SSH-side PATH as logging `.cmd` files; the Linux list plus MSVC and LLVM. */ -export const WINDOWS_FORBIDDEN_TOOLS = [ - ...FORBIDDEN_TOOLS, - 'cl', - 'clang', - 'clang++', - 'msbuild', - 'cmake' -] as const - -export type WindowsHostCell = { - id: string - defaultShell: WindowsSshDefaultShell - remoteRuntime: SshRemoteRuntime -} - -export const WINDOWS_HOST_CELL_IDS = ['pinned-cmd', 'pinned-powershell', 'legacy-opt-out'] as const -export type WindowsHostCellId = (typeof WINDOWS_HOST_CELL_IDS)[number] - -export function isWindowsHostCellId(id: string): id is WindowsHostCellId { - return WINDOWS_HOST_CELL_IDS.some((candidate) => candidate === id) -} - -export function windowsHostCell(id: WindowsHostCellId): WindowsHostCell { - switch (id) { - case 'pinned-cmd': - return { - id, - defaultShell: 'cmd', - remoteRuntime: 'pinned-node' - } - case 'pinned-powershell': - return { - id, - defaultShell: 'powershell', - remoteRuntime: 'pinned-node' - } - case 'legacy-opt-out': - return { - id, - defaultShell: 'cmd', - remoteRuntime: 'legacy' - } - } -} +import type { SshTarget } from '../../shared/ssh-types' /** 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 @@ -70,20 +18,12 @@ export function isWindowsOrcadCellId(id: string): id is WindowsOrcadCellId { export const WINDOWS_CONVERT_CELL_ID = 'orcad-convert' /** The bundled CLI against a managed host (tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts). */ -export const WINDOWS_CLI_MATRIX_CELL_IDS = [ - 'orcad-cli-managed', - 'orcad-cli-convert', - 'orcad-cli-relay-kept' -] as const +export const WINDOWS_CLI_MATRIX_CELL_IDS = ['orcad-cli-managed', 'orcad-cli-convert'] as const export type WindowsCliMatrixCellId = (typeof WINDOWS_CLI_MATRIX_CELL_IDS)[number] -/** Written by config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1, one per run. */ +/** Written by config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1, one per run. */ export type WindowsHostCellDescriptor = { - cell: - | WindowsHostCellId - | WindowsOrcadCellId - | typeof WINDOWS_CONVERT_CELL_ID - | WindowsCliMatrixCellId + cell: WindowsOrcadCellId | typeof WINDOWS_CONVERT_CELL_ID | WindowsCliMatrixCellId target: WindowsServerTarget host: string port: number @@ -110,7 +50,6 @@ export function parseWindowsHostCellDescriptor(text: string): WindowsHostCellDes } const record = Object.fromEntries(Object.entries(parsed)) const cell = - WINDOWS_HOST_CELL_IDS.find((id) => id === record.cell) ?? WINDOWS_ORCAD_CELL_IDS.find((id) => id === record.cell) ?? WINDOWS_CLI_MATRIX_CELL_IDS.find((id) => id === record.cell) ?? (record.cell === WINDOWS_CONVERT_CELL_ID ? WINDOWS_CONVERT_CELL_ID : undefined) @@ -144,18 +83,17 @@ export function readWindowsHostCellDescriptor(path: string): WindowsHostCellDesc export function windowsHostSshTarget( descriptor: WindowsHostCellDescriptor, - cell: Pick, runId: string ): SshTarget { return { - id: `windows-host-${cell.id}-${runId}`, - label: `Windows host ${cell.id}`, + id: `windows-host-${descriptor.cell}-${runId}`, + label: `Windows host ${descriptor.cell}`, source: 'manual', host: descriptor.host, port: descriptor.port, username: descriptor.username, identityFile: descriptor.identityFile, identitiesOnly: true, - remoteRuntime: cell.remoteRuntime + remoteRuntime: 'pinned-node' } } diff --git a/tests/e2e/helpers/orcad-convert-flow.ts b/tests/e2e/helpers/orcad-convert-flow.ts index 9023bcd5298a..c2f83a7f6dff 100644 --- a/tests/e2e/helpers/orcad-convert-flow.ts +++ b/tests/e2e/helpers/orcad-convert-flow.ts @@ -82,7 +82,7 @@ function sourceRows( return { repos: count(state.repos), folderWorkspaces: count(state.folderWorkspaces) } } -export function targetLeases(userData: string, targetId: string): { state?: unknown }[] { +function targetLeases(userData: string, targetId: string): { state?: unknown }[] { const leases = readPersistedProfileState(userData).sshRemotePtyLeases return (Array.isArray(leases) ? leases : []).filter((lease) => lease?.targetId === targetId) } diff --git a/tests/e2e/helpers/ssh-test-target-connection.ts b/tests/e2e/helpers/ssh-test-target-connection.ts deleted file mode 100644 index 3c241ffe6a90..000000000000 --- a/tests/e2e/helpers/ssh-test-target-connection.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { Page } from '@stablyai/playwright-test' -import type { SshTargetCreateInput } from '../../../src/shared/ssh-types' - -export type ConnectedSshTestTarget = { - targetId: string - repoId: string - worktreeId: string -} - -type SshTestConnectionOptions = { - remotePath: string - displayName: string - seedInitialTab?: boolean - /** A target already in the profile (a seeded one): connect it instead of adding `target`. */ - existingTargetId?: string -} - -export async function connectSshTestTarget( - page: Page, - target: SshTargetCreateInput, - options: SshTestConnectionOptions -): Promise { - return page.evaluate( - async ({ target, remotePath, displayName, seedInitialTab, existingTargetId }) => { - const store = window.__store - if (!store) { - throw new Error('Store unavailable') - } - const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { - void window.api.ssh.submitCredential({ - requestId: request.requestId, - value: null - }) - }) - try { - const createdTarget = existingTargetId - ? (await window.api.ssh.listTargets()).find((entry) => entry.id === existingTargetId) - : await window.api.ssh.addTarget({ target }).then((added) => { - store.getState().recordSshRepoReadoptions(added.repoReadoptions) - return added.target - }) - if (!createdTarget) { - throw new Error(`No SSH target ${existingTargetId} in the profile`) - } - const state = await window.api.ssh.connect({ - targetId: createdTarget.id - }) - if (!state || state.status !== 'connected') { - throw new Error(`SSH target did not connect: ${JSON.stringify(state)}`) - } - if ( - !state.providerEpoch || - !Number.isSafeInteger(state.connectionGeneration) || - state.connectionGeneration === undefined || - state.connectionGeneration < 0 - ) { - throw new Error(`SSH target returned incomplete authority: ${JSON.stringify(state)}`) - } - store.getState().setSshConnectionState(createdTarget.id, state) - const labels = new Map(store.getState().sshTargetLabels) - labels.set(createdTarget.id, createdTarget.label) - store.getState().setSshTargetLabels(labels) - const executionHostId = `ssh:${encodeURIComponent(createdTarget.id)}` as const - const authority = { - targetId: createdTarget.id, - providerEpoch: state.providerEpoch, - connectionGeneration: state.connectionGeneration - } - - const result = await window.api.repos.addRemote({ - connectionId: createdTarget.id, - remotePath, - displayName - }) - if ('error' in result) { - throw new Error(result.error) - } - const hasExpectedRepoOwner = (): boolean => - store - .getState() - .repos.some( - (repo) => - repo.id === result.repo.id && - repo.connectionId === createdTarget.id && - repo.executionHostId === executionHostId - ) - const waitForRepoOwner = async (): Promise => { - if (hasExpectedRepoOwner()) { - return - } - await new Promise((resolve, reject) => { - const timer = window.setTimeout(() => { - unsubscribe() - reject(new Error(`Remote repo owner did not hydrate for ${result.repo.path}`)) - }, 15_000) - const unsubscribe = store.subscribe((next) => { - if ( - !next.repos.some( - (repo) => - repo.id === result.repo.id && - repo.connectionId === createdTarget.id && - repo.executionHostId === executionHostId - ) - ) { - return - } - window.clearTimeout(timer) - unsubscribe() - resolve() - }) - }) - } - await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) - await waitForRepoOwner() - const currentState = store.getState().sshConnectionStates.get(createdTarget.id) - if ( - currentState?.providerEpoch !== authority.providerEpoch || - currentState.connectionGeneration !== authority.connectionGeneration - ) { - throw new Error(`SSH authority rotated before worktree hydration for ${result.repo.path}`) - } - const worktreeResult = await store.getState().fetchWorktrees(result.repo.id, { - executionHostId, - directSshAuthority: authority, - requireAuthoritative: true - }) - if ( - worktreeResult.status !== 'complete' || - worktreeResult.repoId !== result.repo.id || - worktreeResult.authority.kind !== 'direct-ssh' || - worktreeResult.authority.executionHostId !== executionHostId || - worktreeResult.authority.targetId !== authority.targetId || - worktreeResult.authority.providerEpoch !== authority.providerEpoch || - worktreeResult.authority.connectionGeneration !== authority.connectionGeneration - ) { - throw new Error( - `Remote worktree hydration was not authoritative: ${JSON.stringify(worktreeResult)}` - ) - } - const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? []).find( - (candidate) => candidate.hostId === executionHostId - ) - if (!worktree) { - throw new Error(`No remote worktree found for ${result.repo.path}`) - } - store.getState().setActiveWorktree(worktree.id) - if (seedInitialTab && (store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { - store.getState().createTab(worktree.id) - } - store.getState().setActiveTabType('terminal', store.getState().activeWorktreeId) - return { - targetId: createdTarget.id, - repoId: result.repo.id, - worktreeId: worktree.id - } - } finally { - credentialUnsub() - } - }, - { - target, - remotePath: options.remotePath, - displayName: options.displayName, - seedInitialTab: options.seedInitialTab ?? true, - existingTargetId: options.existingTargetId - } - ) -} diff --git a/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts b/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts index 62b9d5cb82c3..c781045608b8 100644 --- a/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts +++ b/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts @@ -1,6 +1,6 @@ /** * The bundled `orca` CLI against a real Windows OpenSSH host running managed orcad, one cell per - * test (config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 greps by tag): + * test (config/ci/windows-ssh-provider/invoke-windows-host-cells.ps1 greps by tag): * * - `@orcad-cli-managed`: an empty host deploys; `host list`/`environment list|show` name it; a CLI * terminal survives disconnect/reconnect; `environment rm` refuses a managed server, the stop @@ -8,8 +8,6 @@ * reads unverifiable then live, never retired. * - `@orcad-cli-convert`: a seeded relay-era profile converts, keeps then retires its source, and the * CLI reaches the converted server's projects. - * - `@orcad-cli-relay-kept`: an open relay terminal keeps the host on the relay with its status line; - * once the CLI closes it, the next connect converts. * * Host: `ORCA_E2E_ORCAD_CONVERT_HOST`, a Windows host-cell descriptor. Template: * `ORCA_E2E_ORCAD_CONVERT_TEMPLATE`, built for that host's target. @@ -19,19 +17,11 @@ import os from 'node:os' import path from 'node:path' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' -import { execInTerminal, waitForActivePanePtyId, waitForTerminalOutput } from './helpers/terminal' -import { connectSshTestTarget } from './helpers/ssh-test-target-connection' +import { waitForSessionReady } from './helpers/store' import { createRestartSession } from './helpers/orca-restart' -import { - convertThenRetire, - managedServer, - reconnect, - targetLeases -} from './helpers/orcad-convert-flow' -import { seedRelayEraProfile, seedRelayEraTarget } from './helpers/orcad-upgrade-profile' +import { convertThenRetire, managedServer, reconnect } from './helpers/orcad-convert-flow' +import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' import { ORCAD_CONVERT_HOST_ENV, startOrcadConvertHost } from './helpers/orcad-convert-host' -import { mutateStoppedProfileState } from './helpers/persisted-profile-state' import { orcaCliResult, runCompiledOrcaCli } from './helpers/compiled-orca-cli' import { killHostOrcad, @@ -95,25 +85,6 @@ async function disposeQuietly(session: { dispose: () => Promise }): Promis } } -/** Windows can keep the just-closed app's SQLite file busy for a moment ("disk I/O error"). */ -async function mutateStoppedProfileStateWhenReleased( - userDataDir: string, - mutate: (state: Record) => void -): Promise { - for (let attempt = 1; ; attempt++) { - try { - mutateStoppedProfileState(userDataDir, mutate) - return - } catch (error) { - if (attempt >= 10) { - throw error - } - console.log(`[cli-matrix] stopped profile busy (attempt ${attempt}): ${String(error)}`) - await new Promise((resolve) => setTimeout(resolve, 1_000)) - } - } -} - async function waitForManaged(page: Page, targetId: string): Promise { let environmentId = '' await expect @@ -619,162 +590,3 @@ test('@orcad-cli-convert a seeded relay-era profile converts its Windows host, k rmSync(SCRATCH, { recursive: true, force: true }) } }) - -test('@orcad-cli-relay-kept an open relay terminal keeps a Windows host on the relay until the CLI closes it', async (// oxlint-disable-next-line no-empty-pattern -- This test owns its launches through a restart session. -{}, testInfo) => { - skipUnlessWindowsHost() - test.setTimeout(30 * 60_000) - resetScratch() - const host = startOrcadConvertHost(HOST!, testInfo) - // Long enough that the relay, and its terminal, outlive the app between launches. - // Pinned node: the lane hides host Node, so the default runtime can't start a relay here. - const input = { - ...host.input, - relayGracePeriodSeconds: 900, - remoteRuntime: 'pinned-node' as const - } - const session = createRestartSession(testInfo, launchEnv()) - let app: ElectronApplication | null = null - try { - const first = await session.launch() - app = first.app - await waitForSessionReady(first.page) - // Why retried: Playwright can drop the first main-process evaluate on a cold Windows launch. - const readVersion = (): Promise => - first.app.evaluate(({ app: electronApp }) => electronApp.getVersion()) - const appVersion = await readVersion().catch(readVersion) - await session.close(app) - app = null - // Why a recorded "can't run": the Windows relay needs the template that would otherwise convert. - const targetId = seedRelayEraTarget(session.userDataDir, input, { - managedServerUnavailable: { reason: 'e2e_relay_era', appVersion } - }) - - // Relay era: one shell running on the relay. - const relayEra = await session.launch({ onStderr: sshStderr }) - app = relayEra.app - await waitForSessionReady(relayEra.page) - const remote = await connectSshTestTarget(relayEra.page, input, { - remotePath: host.remoteRepoPath, - displayName: 'orcad cli relay-kept E2E', - seedInitialTab: true, - existingTargetId: targetId - }) - expect(await managedServer(relayEra.page, targetId)).toMatchObject({ - kind: 'relay', - reason: 'orcad_unavailable' - }) - await ensureTerminalVisible(relayEra.page, 45_000) - const ptyId = await waitForActivePanePtyId(relayEra.page, 60_000) - const marker = `ORCA_RELAY_KEPT_${Date.now()}` - await execInTerminal(relayEra.page, ptyId, `echo ${marker}`) - await waitForTerminalOutput(relayEra.page, marker, 30_000) - const localTerminals = await orcaCliResult(session.userDataDir, [ - 'terminal', - 'list', - '--worktree', - `id:${remote.worktreeId}` - ]) - expect(localTerminals.terminals.length, 'the CLI lists the relay terminal').toBeGreaterThan(0) - await session.close(app) - app = null - await mutateStoppedProfileStateWhenReleased(session.userDataDir, (state) => { - const targets = Array.isArray(state.sshTargets) ? state.sshTargets : [] - for (const target of targets) { - if (target?.id === targetId) { - delete target.managedServerUnavailable - } - } - }) - - // Converting build with the relay terminal still open: the host stays on the relay. - const kept = await session.launch({ onStderr: sshStderr }) - app = kept.app - const page = kept.page - await waitForSessionReady(page) - testInfo.annotations.push({ - type: 'kept-connect', - description: await reconnect(page, targetId) - }) - await expect - .poll(async () => JSON.stringify(await managedServer(page, targetId)), { - timeout: 120_000 - }) - .toContain('relay_terminals_live') - const status = await managedServer(page, targetId) - console.log(`[cli-matrix] relay-kept census: ${JSON.stringify(status)}`) - expect(status).toMatchObject({ kind: 'relay', reason: 'relay_terminals_live' }) - const count = - status && - typeof status === 'object' && - 'terminals' in status && - typeof status.terminals === 'number' - ? status.terminals - : 0 - expect(count).toBeGreaterThan(0) - await page.evaluate(() => { - const state = window.__store!.getState() - state.openSettingsTarget({ pane: 'ssh', repoId: null }) - state.openSettingsPage() - }) - const section = page.locator('[data-settings-section="ssh"]') - await expect - .poll(async () => ((await section.count()) ? await section.innerText() : ''), { - timeout: 30_000 - }) - .toMatch(new RegExp(`Runs the relay until its ${count} open terminals? (is|are) closed`, 'u')) - expect((await hostRows(session.userDataDir)).find((row) => row.id === targetId)).toMatchObject({ - kind: 'ssh', - connected: true - }) - - // The CLI closes the relay terminals; with none left, the next connect converts. - // The close must confirm the relay PTY's exit (#25304); its timing tells a lagging record apart. - const closeStartedAt = Date.now() - const closed = await runCompiledOrcaCli(session.userDataDir, [ - 'terminal', - 'close', - '--worktree', - `id:${remote.worktreeId}`, - '--all', - '--json' - ]) - console.log(`[cli-matrix] relay close took ${Date.now() - closeStartedAt}ms: ${closed.stdout}`) - expect(closed.json?.ok, closed.stdout).toBe(true) - await expect - .poll( - async () => - JSON.stringify({ - sessions: await page.evaluate( - (connectionId) => window.api.pty.listSessions({ connectionId }), - targetId - ), - leases: targetLeases(session.userDataDir, targetId).filter( - (lease) => lease.state === 'attached' || lease.state === 'detached' - ) - }), - { timeout: 60_000 } - ) - .toBe(JSON.stringify({ sessions: [], leases: [] })) - testInfo.annotations.push({ - type: 'convert-connect', - description: await reconnect(page, targetId) - }) - const environmentId = await waitForManaged(page, targetId) - expect(await environmentIds(session.userDataDir)).toContain(environmentId) - const repos = await orcaCliResult<{ repos: { path: string }[] }>(session.userDataDir, [ - 'repo', - 'list', - '--environment', - environmentId - ]) - expect(repos.repos.some((repo) => samePath(repo.path, host.remoteRepoPath))).toBe(true) - } finally { - if (app) { - await session.close(app) - } - await disposeQuietly(session) - host.cleanup() - rmSync(SCRATCH, { recursive: true, force: true }) - } -})