Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions .github/workflows/ssh-windows-hosts.yml
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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'
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=<ReturnValue>
# such grant, so a launch that still needs WMI fails its cell. Prints ORCA_WMI=<ReturnValue>
# 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''}"'
Expand Down Expand Up @@ -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]
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
13 changes: 5 additions & 8 deletions src/main/ssh/orcad-windows-host-lane.test.ts
Original file line number Diff line number Diff line change
@@ -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=<descriptor.json> pnpm test <this file>
import { randomUUID } from 'node:crypto'
Expand Down Expand Up @@ -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<SshConnection['exec']> | null = null
const receipt: Record<string, unknown> = { cell: descriptor.cell, target: descriptor.target }
let conn: SshConnection | null = null
Expand Down
51 changes: 12 additions & 39 deletions src/main/ssh/ssh-windows-host-cells.test.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,24 @@
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,
username: 'orcaabc',
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',
Expand All @@ -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<string, unknown>): 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')
Expand Down
Loading
Loading