diff --git a/.github/workflows/diagnostic-windows-ssh-provider.yml b/.github/workflows/diagnostic-windows-ssh-provider.yml new file mode 100644 index 00000000000..12dcf7ca47f --- /dev/null +++ b/.github/workflows/diagnostic-windows-ssh-provider.yml @@ -0,0 +1,138 @@ +name: Diagnostic stable Bun ARM SSH provider qualification +on: + push: + branches: [OrcaWin/np-windows-ssh-provider-diagnostic] + workflow_dispatch: +permissions: + contents: read + actions: read +concurrency: + group: arm-ssh-provider-${{ github.ref }} + cancel-in-progress: false +jobs: + windows_watcher: + if: github.repository == 'stablyai/orca' && github.ref == 'refs/heads/OrcaWin/np-windows-ssh-provider-diagnostic' + uses: ./.github/workflows/windows-watcher-artifacts.yml + with: + ref: 2084c58ba5410106ce61153a9fb16cdb4b6e5301 + qualify: + needs: windows_watcher + runs-on: windows-11-arm + timeout-minutes: 45 + env: + ORCA_BACKGROUND_LAUNCH: '1' + ORCA_ISOLATED_SSH_CI: '1' + QUALIFICATION_SOURCE_SHA: 2084c58ba5410106ce61153a9fb16cdb4b6e5301 + PRODUCER_RUN: '36503596770' + steps: + - uses: actions/checkout@v6 + with: + ref: 2084c58ba5410106ce61153a9fb16cdb4b6e5301 + persist-credentials: false + - uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + path: .build/qualification-tools + sparse-checkout: config/ci/windows-ssh-provider + persist-credentials: false + - name: Require matching product and watcher source + shell: pwsh + env: + WATCHER_SOURCE_SHA: ${{ needs.windows_watcher.outputs.source_sha }} + run: | + if((git rev-parse HEAD).Trim() -ne $env:QUALIFICATION_SOURCE_SHA -or $env:WATCHER_SOURCE_SHA -ne $env:QUALIFICATION_SOURCE_SHA){throw 'Product/watcher source mismatch'} + New-Item -ItemType Directory -Force .build/ssh-provider-receipts | Out-Null + - uses: actions/download-artifact@v8 + with: + github-token: ${{ github.token }} + run-id: '36503596770' + name: full-offline-patched-bun + path: .build/producer + - name: Retain real producer run identity + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/stablyai/orca/actions/runs/$env:PRODUCER_RUN" | Out-File -Encoding utf8NoBOM .build/producer/producer-run.json + if($LASTEXITCODE -ne 0){throw 'Producer identity lookup failed'} + - uses: ./.github/actions/load-windows-watcher-artifacts + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Admit exact stable candidate before provisioning + shell: pwsh + run: | + $tools=Join-Path $pwd '.build/qualification-tools/config/ci/windows-ssh-provider' + node (Join-Path $tools 'verify-bun-output.mjs') candidate .build/producer . arm64 .build/ssh-provider-receipts/candidate.json $env:PRODUCER_RUN + if($LASTEXITCODE -ne 0){throw 'Stable producer/candidate admission failed'} + $receipt=(Resolve-Path .build/ssh-provider-receipts/candidate.json).Path + $hash=(Get-FileHash -Algorithm SHA256 -LiteralPath $receipt).Hash.ToLowerInvariant() + "CANDIDATE_RECEIPT=$receipt" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "CANDIDATE_RECEIPT_SHA256=$hash" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + @{candidateReceiptSha256=$hash;producerRun=$env:PRODUCER_RUN;product=$env:QUALIFICATION_SOURCE_SHA;candidateOverride=$true;expectedSourcePin=$false;scope='diagnostic in-memory ARM executable pin and private cache only'} | ConvertTo-Json | Set-Content .build/ssh-provider-receipts/admission.json + - name: Build production relay artifacts and native process table + shell: pwsh + run: | + node config/scripts/build-cli-runtime.mjs --platform win32 --arch arm64 + if($LASTEXITCODE -ne 0){throw 'CLI runtime build failed'} + node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64 + if($LASTEXITCODE -ne 0){throw 'Native process-table build failed'} + $env:ORCA_REQUIRE_RELAY_NATIVE_ADDONS='arm64' + node config/scripts/build-relay.mjs + if($LASTEXITCODE -ne 0){throw 'Relay build failed'} + - name: Run watcher fault harness with production-pinned CLI Bun + shell: pwsh + timeout-minutes: 5 + run: | + Write-Output 'Scope: production-pinned bundled CLI Bun; actual SSH provider qualification separately uses the admitted stable candidate.' | Tee-Object .build/ssh-provider-receipts/watcher-fault-arm64.log + node config/scripts/relay-watcher-fault-harness.mjs 2>&1 | Tee-Object -Append .build/ssh-provider-receipts/watcher-fault-arm64.log + if($LASTEXITCODE -ne 0){throw 'Production-pinned CLI Bun watcher fault harness failed'} + - name: Parse and typecheck all fixture code before provisioning + shell: pwsh + run: | + $tools=Join-Path $pwd '.build/qualification-tools/config/ci/windows-ssh-provider' + foreach($file in @((Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1'),(Join-Path $tools 'preview-ssh/test-preview-diagnostics.ps1'),(Join-Path $tools 'invoke-provider-route.ps1'))){ + $errors=$null;$tokens=$null + [Management.Automation.Language.Parser]::ParseFile($file,[ref]$tokens,[ref]$errors)|Out-Null + if($errors.Count){throw "PowerShell parse failed: $file"} + } + & (Join-Path $tools 'preview-ssh/test-preview-diagnostics.ps1') + $copied=[Collections.Generic.List[string]]::new() + try { + foreach($name in @('windows-arm-provider-route.test.ts','windows-provider-candidate.ts','windows-provider-repaint.ts','windows-provider-loaded-images.ts')){ + $destination=Join-Path $pwd "src/main/ssh/$name" + if(Test-Path -LiteralPath $destination){throw 'Fixture destination already exists'} + Copy-Item -LiteralPath (Join-Path $tools $name) -Destination $destination + $copied.Add($destination) + } + node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json 2>&1 | Tee-Object .build/ssh-provider-receipts/typecheck.log + if($LASTEXITCODE -ne 0){throw 'Fixture typecheck failed'} + } finally { + foreach($destination in $copied){Remove-Item -LiteralPath $destination -Force} + } + - name: Qualify standard-user SSH deployment, loaded provider and exact resize replay + shell: pwsh + timeout-minutes: 18 + run: | + $tools=Join-Path $pwd '.build/qualification-tools/config/ci/windows-ssh-provider' + $sourceRoot=$pwd.Path + $archive=Join-Path $env:RUNNER_TEMP 'preview-OpenSSH-ARM64.zip' + & "$env:WINDIR\System32\curl.exe" --fail --location --connect-timeout 15 --max-time 90 --output $archive 'https://github.com/PowerShell/Win32-OpenSSH/releases/download/10.0.0.0p2-Preview/OpenSSH-ARM64.zip' + if($LASTEXITCODE -ne 0){throw 'SSH archive fetch failed'} + $callback={param($user,$port,$identity,$known) + & (Join-Path $tools 'invoke-provider-route.ps1') -SourceRoot $sourceRoot -SourceCommit $env:QUALIFICATION_SOURCE_SHA -Username $user -Port $port -IdentityFile $identity -KnownHosts $known -ReceiptRoot "$env:RUNNER_TEMP\arm-provider-route" -CandidateReceipt $env:CANDIDATE_RECEIPT -CandidateReceiptSha256 $env:CANDIDATE_RECEIPT_SHA256 + }.GetNewClosure() + & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Receipt "$env:RUNNER_TEMP\arm-provider-server.json" -ProductionRouteProbe $callback 2>&1 | Tee-Object .build/ssh-provider-receipts/native-route.log + - uses: actions/upload-artifact@v7 + if: always() + with: + name: stable-bun-arm-ssh-provider-receipts + path: | + .build/ssh-provider-receipts/ + .build/producer/results/ + .build/producer/work/source-cache-receipt.json + .build/producer/producer-run.json + ${{ runner.temp }}/arm-provider-server.json + ${{ runner.temp }}/arm-provider-route/production-route.json + ${{ runner.temp }}/arm-provider-route/repaint-events.json + retention-days: 7 diff --git a/config/ci/windows-ssh-provider/invoke-provider-route.ps1 b/config/ci/windows-ssh-provider/invoke-provider-route.ps1 new file mode 100644 index 00000000000..5d81bdd1740 --- /dev/null +++ b/config/ci/windows-ssh-provider/invoke-provider-route.ps1 @@ -0,0 +1,54 @@ +param( + [Parameter(Mandatory=$true)][string]$SourceRoot, + [Parameter(Mandatory=$true)][string]$SourceCommit, + [Parameter(Mandatory=$true)][string]$Username, + [Parameter(Mandatory=$true)][int]$Port, + [Parameter(Mandatory=$true)][string]$IdentityFile, + [Parameter(Mandatory=$true)][string]$KnownHosts, + [Parameter(Mandatory=$true)][string]$ReceiptRoot, + [Parameter(Mandatory=$true)][string]$CandidateReceipt, + [Parameter(Mandatory=$true)][string]$CandidateReceiptSha256 +) +$ErrorActionPreference='Stop' +if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1'){throw 'Disposable CI only'} +if($SourceCommit -notmatch '^[a-f0-9]{40}$'){throw 'Exact source hash required'} +Push-Location $SourceRoot +$knownPath=Join-Path ([Environment]::GetFolderPath('UserProfile')) '.ssh\known_hosts' +$priorKnown=$null +$copiedPaths=[Collections.Generic.List[string]]::new() +$priorBackground=$env:ORCA_BACKGROUND_LAUNCH +$knownExisted=Test-Path -LiteralPath $knownPath +if($knownExisted){$priorKnown=[IO.File]::ReadAllBytes($knownPath)} +try { + $observed=(& git rev-parse HEAD).Trim() + if($LASTEXITCODE -ne 0 -or $observed -ne $SourceCommit){throw 'Source checkout mismatch'} + if(!(Test-Path 'out\relay\win32-arm64\relay.js')){throw 'Build real relay artifacts before server provisioning'} + New-Item -ItemType Directory -Path $ReceiptRoot -Force | Out-Null + New-Item -ItemType Directory -Path (Split-Path $knownPath) -Force | Out-Null + $pinned=[IO.File]::ReadAllText($KnownHosts) + if($pinned -notmatch [regex]::Escape("[127.0.0.1]:$Port")){throw 'Missing private endpoint host-key pin'} + Add-Content -LiteralPath $knownPath -Value "`n$pinned" + if($CandidateReceiptSha256 -notmatch '^[a-f0-9]{64}$' -or (Get-FileHash -Algorithm SHA256 -LiteralPath $CandidateReceipt).Hash.ToLowerInvariant() -ne $CandidateReceiptSha256){throw 'Preverified candidate admission receipt mismatch'} + $env:ORCA_BACKGROUND_LAUNCH='1' + $env:ORCA_SSH_PROBE_STATE=Join-Path $ReceiptRoot 'state' + New-Item -ItemType Directory -Path $env:ORCA_SSH_PROBE_STATE -Force | Out-Null + $env:ORCA_RELAY_PATH=Join-Path $SourceRoot 'out\relay' + $env:ORCA_SSH_PROBE_CONFIG=Join-Path $ReceiptRoot 'config.json' + @{sourceCommit=$SourceCommit;observedSourceCommit=$observed;username=$Username;port=$Port;identityFile=$IdentityFile;candidateReceiptPath=$CandidateReceipt;candidateReceiptSha256=$CandidateReceiptSha256;receiptPath=(Join-Path $ReceiptRoot 'production-route.json')} | ConvertTo-Json | Set-Content $env:ORCA_SSH_PROBE_CONFIG + foreach($name in @('windows-arm-provider-route.test.ts','windows-provider-candidate.ts','windows-provider-repaint.ts','windows-provider-loaded-images.ts')) { + $destination=Join-Path $SourceRoot "src\main\ssh\$name" + if(Test-Path -LiteralPath $destination){throw 'Diagnostic source destination already exists'} + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination $destination + $copiedPaths.Add($destination) + } + & node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/ssh/windows-arm-provider-route.test.ts --no-file-parallelism --reporter=verbose + if($LASTEXITCODE -ne 0){throw 'Production SSH route qualification failed'} + $result=Get-Content (Join-Path $ReceiptRoot 'production-route.json') -Raw | ConvertFrom-Json + if(!$result.sameShellState -or !$result.cleanupVerified -or !$result.sourcePinRestored -or !$result.candidateAdmission.candidateOverride -or $result.repaint.koreanRows -ne 8 -or $result.repaint.latinRows -ne 8 -or !$result.repaint.exactRowsOnly -or !$result.repaint.provider.modules){throw 'Provider route cleanup/evidence incomplete'} +} finally { + foreach($destination in $copiedPaths) { Remove-Item -LiteralPath $destination -Force } + $env:ORCA_BACKGROUND_LAUNCH=$priorBackground + if($knownExisted){[IO.File]::WriteAllBytes($knownPath,$priorKnown)}else{Remove-Item -LiteralPath $knownPath -Force -ErrorAction SilentlyContinue} + Remove-Item Env:ORCA_SSH_PROBE_CONFIG,Env:ORCA_SSH_PROBE_STATE,Env:ORCA_RELAY_PATH -ErrorAction SilentlyContinue + Pop-Location +} diff --git a/config/ci/windows-ssh-provider/preview-ssh/preview-native-inputs.json b/config/ci/windows-ssh-provider/preview-ssh/preview-native-inputs.json new file mode 100644 index 00000000000..1bd9137c939 --- /dev/null +++ b/config/ci/windows-ssh-provider/preview-ssh/preview-native-inputs.json @@ -0,0 +1,96 @@ +{ + "release": "10.0.0.0p2-Preview", + "archiveSha256": "698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42", + "files": [ + { + "name": "libcrypto.dll", + "sha256": "7ad2b7721893c54ad6e4fec1a3477701fb48975323c2c4ac6cd0b8c972ab242a", + "machine": "0xAA64", + "certificateTableBytes": 10288 + }, + { + "name": "scp.exe", + "sha256": "53115de3294c52a3a2cc8b87a29ff71daa42cd72c6783ded3f12eac8e92662c1", + "machine": "0xAA64", + "certificateTableBytes": 10312 + }, + { + "name": "sftp-server.exe", + "sha256": "f3a2f3b27094ec12518eafdcc39d1cdfd6f1550eac5c24850314a1a776783b8b", + "machine": "0xAA64", + "certificateTableBytes": 10312 + }, + { + "name": "sftp.exe", + "sha256": "5e5560d2acb920e84f15762680be8542104ad58c508ce1d1ecca43408b26274d", + "machine": "0xAA64", + "certificateTableBytes": 10272 + }, + { + "name": "ssh-add.exe", + "sha256": "64ec90bd7bfff3f95720a2af2d954026d47eddcb91b1a9af8a62734d0319c63b", + "machine": "0xAA64", + "certificateTableBytes": 10296 + }, + { + "name": "ssh-agent.exe", + "sha256": "4dabe24c9439aefb024610112aac6866990be5165ffd1a2f8b8281af2e458064", + "machine": "0xAA64", + "certificateTableBytes": 10272 + }, + { + "name": "ssh-keygen.exe", + "sha256": "c94940e4ea52fb073e460532884e0c14202e631db1d488a3a681d8230d57e0d6", + "machine": "0xAA64", + "certificateTableBytes": 10312 + }, + { + "name": "ssh-keyscan.exe", + "sha256": "e68635da703812670aacf762a474191fe6cc25929bac5a498b3ea4c08667a79d", + "machine": "0xAA64", + "certificateTableBytes": 10272 + }, + { + "name": "ssh-pkcs11-helper.exe", + "sha256": "95db4da86676ffe4595a037d356d01755f4f6a4267049fdda0b0c9f97a97d2c9", + "machine": "0xAA64", + "certificateTableBytes": 10312 + }, + { + "name": "ssh-shellhost.exe", + "sha256": "d89f9a420268120789cf9dc1b94ef4313da1a258c3c57c172eda2fe999ded7e4", + "machine": "0xAA64", + "certificateTableBytes": 10312 + }, + { + "name": "ssh-sk-helper.exe", + "sha256": "b563dfb6ee18b3b761cae0bfde212295e7fec5c6b9fb67ea1b19fc424bb805e4", + "machine": "0xAA64", + "certificateTableBytes": 10296 + }, + { + "name": "ssh.exe", + "sha256": "fd87ccdfbd8be33d22b67fa3f1c94bb2327a3e7d24355bf7fd2485d356f1976d", + "machine": "0xAA64", + "certificateTableBytes": 10296 + }, + { + "name": "sshd-auth.exe", + "sha256": "0c7ca28ed3649ef6f0ce1b3698cc7a92f51b86286fc077d5513f1bf5e48b178f", + "machine": "0xAA64", + "certificateTableBytes": 10272 + }, + { + "name": "sshd-session.exe", + "sha256": "9f368188f703bd39594abefc29f28df5e97664acb937a1d26288b500ece4d463", + "machine": "0xAA64", + "certificateTableBytes": 10272 + }, + { + "name": "sshd.exe", + "sha256": "f3eb3230d454dc662d5c5f09eface5acdeb2b196ccdd41b562644433a1562906", + "machine": "0xAA64", + "certificateTableBytes": 10312 + } + ] +} 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 new file mode 100644 index 00000000000..4cc2f764989 --- /dev/null +++ b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 @@ -0,0 +1,329 @@ +# Ephemeral CI only. Preview ZIP server qualification, not inbox capability coverage. +param([Parameter(Mandatory=$true)][string]$Receipt,[Parameter(Mandatory=$true)][string]$Archive,[scriptblock]$ProductionRouteProbe) +$ErrorActionPreference = 'Stop' +$report = @{scope='Microsoft Win32-OpenSSH 10.0.0.0p2-Preview ARM64 private loopback authentication and stock cmd.exe dispatch; NOT inbox server or relay deployment'; status='running'; imageVersion=$env:ImageVersion; cleanup=@('not-confirmed'); globalBootstrapCleanup='Not qualified: service bootstrap may create ProgramData SSH and OpenSSH registry entries; disposable CI VM destruction is the boundary'; observations=@(); stages=@(); diagnosticCaptureFailures=@()} +$script:receiptWritten=$false +function Write-Stage([string]$Stage) { + $timestamp=[DateTime]::UtcNow.ToString('o') + $report.stages += @{stage=$Stage; utc=$timestamp} + try { + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($report | ConvertTo-Json -Depth 6)) + $temporary="$Receipt.pending" + $stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} + [IO.File]::Move($temporary,$Receipt,$true) + $script:receiptWritten=$true + } catch {Write-Warning 'Progress receipt could not be updated; cleanup must still run'} + Write-Host "Native SSH stage: $Stage ($timestamp)" +} +Write-Stage 'preflight-start' +if(-not $script:receiptWritten){throw 'Initial progress receipt unavailable; refuse provisioning'} +if ($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1' -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ne 'Arm64') { throw 'Requires isolated native ARM64 GitHub runner' } +$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $admin) { throw 'Administrative private service/account setup required' } +Write-Stage 'existing-server-query-start' +if(Get-Service sshd -ErrorAction SilentlyContinue){throw 'Refuse an existing global SSH server'} +Write-Stage 'existing-server-query-complete' +Write-Stage 'default-shell-query-start' +$registry = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\OpenSSH' -ErrorAction SilentlyContinue +if ($registry.DefaultShell -or $registry.DefaultShellCommandOption) { throw 'Requires stock cmd.exe OpenSSH shell; never rewrite registry' } +Write-Stage 'default-shell-query-complete' +$id = [Guid]::NewGuid().ToString('N').Substring(0,10) +$name = "orca$id" +$serviceName = "orca-sshd-$id" +$root = Join-Path $env:RUNNER_TEMP "ossh-$id" +Write-Stage 'private-directory-create-start' +New-Item -ItemType Directory -Path $root | Out-Null +Write-Stage 'private-directory-create-complete' +$report.root=$root +$createdUser=$false; $createdService=$false; $sid=$null; $ownedServerPid=$null +$sshDir=Join-Path $root 'OpenSSH-ARM64' +$sshdLog=Join-Path $root 'private-sshd.log' +$serviceStartAttempt=$null +function Diagnostic-Categories([string]$Text) { + $categories=@() + foreach($category in @('connection established','remote protocol version','server host key','host key verification failed','offering public key','server accepts key','authenticated to','sending command','exit status','permission denied','connection closed','connection reset','bad permissions','unable to load host key','no hostkeys available','failed to create','fatal','userauth','accepted publickey','starting session','createprocess','logonuser')){ + if($Text.IndexOf($category,[StringComparison]::OrdinalIgnoreCase) -ge 0){$categories+=$category} + } + return $categories +} +function Diagnostic-ExitStatuses([string]$Text) { + $bounded=$Text.Substring(0,[Math]::Min($Text.Length,16384)) + $matches=[regex]::Matches($bounded,'(?im)\b(?:exit status|exit code|error(?: code)?)\s*[:=]?\s*(-?\d{1,10})\b') + return @($matches | Select-Object -First 8 | ForEach-Object {[long]$_.Groups[1].Value}) +} +function Invoke-Bounded([string]$Program,[string[]]$Arguments,[int]$Seconds=20,[switch]$AllowFailure) { + Write-Stage ('command-'+[IO.Path]::GetFileName($Program)+'-start') + $start=[Diagnostics.ProcessStartInfo]::new($Program) + $start.UseShellExecute=$false; $start.CreateNoWindow=$true + $start.RedirectStandardOutput=$true; $start.RedirectStandardError=$true + foreach($argument in $Arguments){$start.ArgumentList.Add($argument)} + $process=[Diagnostics.Process]::new();$process.StartInfo=$start + try { + if(-not $process.Start()){throw 'Owned command failed to start'} + $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() + $timedOut=-not $process.WaitForExit($Seconds*1000) + if($timedOut){$process.Kill($true);if(-not $process.WaitForExit(5000)){throw 'Owned command kill unconfirmed'}} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Owned command output drain deadline exceeded'} + $output=$stdout.GetAwaiter().GetResult();$errorText=$stderr.GetAwaiter().GetResult() + if($output.Length+$errorText.Length -gt 1048576){throw 'Owned command output limit exceeded'} + if([IO.Path]::GetFileName($Program) -eq 'ssh.exe'){ + $report.sshClient=@{timedOut=$timedOut;exitCode=$process.ExitCode;stderrBytes=$errorText.Length;categories=@(Diagnostic-Categories $errorText);reportedExitStatuses=@(Diagnostic-ExitStatuses $errorText)} + Write-Stage 'ssh-client-result' + } + if([IO.Path]::GetFileName($Program) -eq 'sftp.exe'){ + $report.sftpClient=@{timedOut=$timedOut;exitCode=$process.ExitCode;stderrBytes=$errorText.Length;categories=@(Diagnostic-Categories $errorText);reportedExitStatuses=@(Diagnostic-ExitStatuses $errorText)} + Write-Stage 'sftp-client-result' + } + if($timedOut){throw 'Owned command deadline exceeded'} + if($process.ExitCode -ne 0 -and -not $AllowFailure){throw "Owned command failed: $([IO.Path]::GetFileName($Program)) exit $($process.ExitCode)"} + Write-Stage ('command-'+[IO.Path]::GetFileName($Program)+'-complete') + return @{code=$process.ExitCode; stdout=$output} + } finally {$process.Dispose()} +} +function Record-PrivateServiceDiagnostics([switch]$AfterStop) { + Write-Stage 'private-service-diagnostics-start' + $captureStage='service-query' + try { + $state=Get-CimInstance Win32_Service -Filter "Name='$serviceName'" + if($state){ + $diagnostic=@{state=$state.State;exitCode=$state.ExitCode;serviceSpecificExitCode=$state.ServiceSpecificExitCode;pid=$state.ProcessId;localSystem=($state.StartName -eq 'LocalSystem');privatePath=($state.PathName -like "*$root*")} + } else {$diagnostic=@{absent=$true}} + if($AfterStop){$report.serviceAfterStop=$diagnostic}else{$report.serviceDiagnostics=$diagnostic} + if($serviceStartAttempt -and -not $AfterStop){ + try { + $events=@(Get-WinEvent -FilterHashtable @{LogName='System';ProviderName='Service Control Manager';StartTime=$serviceStartAttempt} -MaxEvents 50 -ErrorAction Stop | Where-Object {$_.Properties.Value -contains $serviceName}) + $report.serviceEvents=@($events | ForEach-Object {@{id=$_.Id;utc=$_.TimeCreated.ToUniversalTime().ToString('o');level=$_.Level}}) + } catch {$report.serviceEventsUnavailable=$true} + } + $captureStage='private-log' + if(Test-Path -LiteralPath $sshdLog){ + $file=[IO.FileStream]::new($sshdLog,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite) + try { + $buffer=[byte[]]::new(16384) + $file.Position=[Math]::Max(0,$file.Length-$buffer.Length) + $offset=$file.Position + $count=$file.Read($buffer,0,$buffer.Length) + $text=[Text.Encoding]::UTF8.GetString($buffer,0,$count) + $classes=@(Diagnostic-Categories $text) + $report.privateLog=@{exists=$true;bytes=$file.Length;examinedBytes=$count;offset=$offset;errorClasses=$classes;reportedExitStatuses=@(Diagnostic-ExitStatuses $text)} + } finally {$file.Dispose()} + } else {$report.privateLog=@{exists=$false}} + } catch {$report.diagnosticCaptureFailures+=@{stage=$captureStage;afterStop=[bool]$AfterStop;hresult=$_.Exception.HResult;kind=$_.Exception.GetType().Name}} + Write-Stage 'private-service-diagnostics-complete' +} + +function Machine([string]$Path){ + $file=[IO.File]::OpenRead($Path) + try{$reader=[IO.BinaryReader]::new($file);$file.Position=0x3c;$position=$reader.ReadInt32();$file.Position=$position;if($reader.ReadUInt32()-ne 0x00004550){throw 'Invalid PE'};return ('0x{0:X4}'-f $reader.ReadUInt16())}finally{$file.Dispose()} +} +try { + Write-Stage 'preview-archive-verify-start' + $expectedArchive='698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42' + if((Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expectedArchive){throw 'Preview archive hash mismatch'} + $report.archiveSha256=$expectedArchive + Write-Stage 'preview-archive-verify-complete' + Write-Stage 'preview-extract-start' + # The exact hash is checked before extraction; never execute included installer scripts. + [IO.Compression.ZipFile]::ExtractToDirectory($Archive,$root) + Write-Stage 'preview-extract-complete' + Write-Stage 'preview-native-input-verification-start' + $manifest=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'preview-native-inputs.json') -Raw | ConvertFrom-Json + if($manifest.archiveSha256 -ne $expectedArchive -or $manifest.files.Count -ne 15){throw 'Preview input manifest mismatch'} + $nativeFiles=@(Get-ChildItem -LiteralPath $sshDir -File | Where-Object {$_.Extension -in @('.exe','.dll')}) + if($nativeFiles.Count -ne $manifest.files.Count){throw 'Unexpected preview native input count'} + $verified=@() + foreach($file in $nativeFiles){ + $expected=@($manifest.files | Where-Object name -eq $file.Name) + if($expected.Count -ne 1 -or (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected[0].sha256){throw 'Preview native input hash mismatch'} + if((Machine $file.FullName) -ne '0xAA64'){throw 'Preview native input is not ARM64'} + $signature=Get-AuthenticodeSignature -LiteralPath $file.FullName + if($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch '(?:^|, )O=Microsoft Corporation(?:,|$)'){throw 'Preview native input Microsoft signature invalid'} + $verified+=@{name=$file.Name;sha256=$expected[0].sha256;machine='0xAA64';signature='Valid';publisher=$signature.SignerCertificate.Subject} + } + $report.nativeInputs=$verified + Write-Stage 'preview-native-input-verification-complete' + $sshd=Join-Path $sshDir 'sshd.exe';$ssh=Join-Path $sshDir 'ssh.exe';$keygen=Join-Path $sshDir 'ssh-keygen.exe' + $password=ConvertTo-SecureString ([Guid]::NewGuid().ToString('N')+'aA!7') -AsPlainText -Force + Write-Stage 'private-user-collision-query-start' + if(Get-LocalUser -Name $name -ErrorAction SilentlyContinue){throw 'Private username collision'} + Write-Stage 'private-user-collision-query-complete' + $createdUser=$true + Write-Stage 'private-user-create-start' + $user=New-LocalUser -Name $name -Password $password -AccountNeverExpires -PasswordNeverExpires -Description 'Ephemeral Orca SSH qualification' + Write-Stage 'private-user-create-complete' + $sid=$user.SID.Value + Write-Stage 'private-user-group-start' + Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user + Write-Stage 'private-user-group-complete' + # No administrator membership, real runner auth files or global DefaultShell modifications. + Invoke-Bounded icacls.exe @($root,'/inheritance:r','/grant:r','*S-1-5-18:(OI)(CI)F','*S-1-5-32-544:(OI)(CI)F',"*$($sid):(RX)") | Out-Null + # /T visits files too: grant direct rights instead of directory-only inheritance flags. + $runnerSid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value + Invoke-Bounded icacls.exe @($sshDir,'/inheritance:r','/grant:r','*S-1-5-18:F','*S-1-5-32-544:F',"*$($runnerSid):F","*$($sid):RX",'/T') | Out-Null + $report.nativeAcl=@{directory=(Get-Acl -LiteralPath $sshDir).Sddl;keygen=(Get-Acl -LiteralPath $keygen).Sddl} + Write-Stage 'native-acl-recorded' + $hostKey=Join-Path $root 'host_key';$clientKey=Join-Path $root 'client_key' + Write-Stage 'private-key-create-start' + Invoke-Bounded $keygen @('-q','-t','ed25519','-N','','-f',$hostKey) | Out-Null + Invoke-Bounded $keygen @('-q','-t','ed25519','-N','','-f',$clientKey) | Out-Null + Write-Stage 'private-key-create-complete' + # Service host keys are readable only by SYSTEM and administrators. + Invoke-Bounded icacls.exe @($hostKey,'/inheritance:r','/grant:r','*S-1-5-18:F','*S-1-5-32-544:F') | Out-Null + Invoke-Bounded icacls.exe @($hostKey,'/setowner','*S-1-5-18') | Out-Null + $hostAcl=Get-Acl -LiteralPath $hostKey + $hostAcl.SetSecurityDescriptorSddlForm('D:P(A;;FA;;;SY)(A;;FA;;;BA)',[Security.AccessControl.AccessControlSections]::Access) + Set-Acl -LiteralPath $hostKey -AclObject $hostAcl + $report.hostKeyAcl=(Get-Acl -LiteralPath $hostKey).Sddl + $authorized=Join-Path $root 'authorized_keys' + Copy-Item -LiteralPath "$clientKey.pub" -Destination $authorized + Invoke-Bounded icacls.exe @($authorized,'/inheritance:r','/grant:r','*S-1-5-18:F','*S-1-5-32-544:F',"*$($sid):R") | Out-Null + $listener=[Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0);$listener.Start();$port=$listener.LocalEndpoint.Port;$listener.Stop() + $config=Join-Path $root 'sshd_config' + $hostPosix=$hostKey.Replace('\','/');$authPosix=$authorized.Replace('\','/');$pidPosix=(Join-Path $root 'sshd.pid').Replace('\','/') + $sftpServer=Join-Path $sshDir 'sftp-server.exe' + $sftpServerPosix=$sftpServer.Replace('\','/') + $report.sftpServer=@{pinnedPath=$true;sha256=(Get-FileHash -LiteralPath $sftpServer -Algorithm SHA256).Hash.ToLowerInvariant();acl=(Get-Acl -LiteralPath $sftpServer).Sddl} + @" +Port $port +ListenAddress 127.0.0.1 +HostKey "$hostPosix" +PidFile "$pidPosix" +AuthorizedKeysFile "$authPosix" +AllowUsers $name +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +StrictModes yes +AllowTcpForwarding no +AllowAgentForwarding no +PermitTunnel no +PermitTTY no +Subsystem sftp "$sftpServerPosix" +LogLevel DEBUG1 +"@ | Set-Content -LiteralPath $config -Encoding ascii + Write-Stage 'server-config-validate-start' + Invoke-Bounded $sshd @('-t','-f',$config) | Out-Null + Write-Stage 'server-config-validate-complete' + # A distinct LocalSystem service supplies Windows sshd's token-creation privileges. + Write-Stage 'private-service-collision-query-start' + if(Get-Service -Name $serviceName -ErrorAction SilentlyContinue){throw 'Private service name collision'} + Write-Stage 'private-service-collision-query-complete' + $createdService=$true + Write-Stage 'private-service-create-start' + New-Service -Name $serviceName -BinaryPathName "`"$sshd`" -f `"$config`" -E `"$sshdLog`"" -StartupType Manual | Out-Null + Write-Stage 'private-service-create-complete' + Invoke-Bounded sc.exe @('privs',$serviceName,'SeAssignPrimaryTokenPrivilege/SeTcbPrivilege/SeBackupPrivilege/SeRestorePrivilege/SeImpersonatePrivilege') | Out-Null + Write-Stage 'private-service-start-start' + $serviceStartAttempt=[DateTime]::Now.AddSeconds(-1) + Start-Service -Name $serviceName + Write-Stage 'private-service-start-complete' + Write-Stage 'private-service-identity-start' + $service=Get-CimInstance Win32_Service -Filter "Name='$serviceName'" + Write-Stage 'private-service-identity-complete' + if($service.StartName -ne 'LocalSystem' -or -not $service.ProcessId){throw 'Private service identity unavailable'} + $ownedServerPid=$service.ProcessId + $keyFields=(Get-Content -LiteralPath "$hostKey.pub" -Raw).Trim().Split(' ') + $known=Join-Path $root 'known_hosts' + "[127.0.0.1]:$port $($keyFields[0]) $($keyFields[1])" | Set-Content -LiteralPath $known -Encoding ascii + $nonce=[Guid]::NewGuid().ToString('N') + $sshArgs=@('-v','-F','NUL','-T','-p',[string]$port,'-i',$clientKey,'-o','BatchMode=yes','-o','IdentitiesOnly=yes','-o','StrictHostKeyChecking=yes','-o',"UserKnownHostsFile=$known",'-o','ConnectTimeout=5',"$name@127.0.0.1") + $deadline=[DateTime]::UtcNow.AddSeconds(75);$probe=$null + $report.sshFirstLoginBudgetSeconds=60 + Write-Stage 'ssh-authentication-start' + do { + $remainingSeconds=[Math]::Max(1,[Math]::Min(60,[Math]::Floor(($deadline-[DateTime]::UtcNow).TotalSeconds))) + $attemptClock=[Diagnostics.Stopwatch]::StartNew() + try {$probe=Invoke-Bounded $ssh ($sshArgs+@("echo $nonce && whoami && echo %COMSPEC%")) $remainingSeconds -AllowFailure} + finally {$report.sshAttemptElapsedMs=$attemptClock.ElapsedMilliseconds;Write-Stage 'ssh-attempt-finished'} + if($probe.code -eq 0){break};Start-Sleep -Milliseconds 250 + } while([DateTime]::UtcNow -lt $deadline) + if($probe.code -ne 0 -or $probe.stdout -notmatch [regex]::Escape($nonce) -or $probe.stdout -notmatch "\\$name(?:\r?\n)" -or $probe.stdout -notmatch '(?i)cmd.exe'){throw 'Real SSH authentication/default-shell proof failed'} + Write-Stage 'ssh-authentication-complete' + Write-Stage 'listener-identity-start' + $listeners=@(Get-NetTCPConnection -State Listen -LocalPort $port) + Write-Stage 'listener-identity-complete' + if(-not $listeners -or @($listeners|Where-Object {$_.LocalAddress -ne '127.0.0.1' -or $_.OwningProcess -ne $ownedServerPid}).Count){throw 'Listener escaped private loopback owner'} + $report.observations=@{serverMachine=(Machine $sshd);clientMachine=(Machine $ssh);publisherVerified=$true;serviceAccount='LocalSystem';dedicatedUser=$true;pinnedHostKey=$true;stockCmdDispatch=$true;loopbackOnly=$true;port=$port;servicePid=$ownedServerPid} + if ($ProductionRouteProbe) { + Write-Stage 'sftp-preflight-start' + $sftpBatch=Join-Path $root 'sftp-probe.txt' + "pwd`nquit" | Set-Content -LiteralPath $sftpBatch -Encoding ascii + $sftp=Join-Path $sshDir 'sftp.exe' + $sftpArgs=@('-v','-S',$ssh,'-F','NUL','-P',[string]$port,'-i',$clientKey,'-b',$sftpBatch,'-o','BatchMode=yes','-o','IdentitiesOnly=yes','-o','StrictHostKeyChecking=yes','-o',"UserKnownHostsFile=$known",'-o','ConnectTimeout=5',"$name@127.0.0.1") + $sftpProof=Invoke-Bounded $sftp $sftpArgs 30 -AllowFailure + if($sftpProof.code -ne 0){throw 'Pinned native SFTP subsystem preflight failed'} + $report.sftpSubsystem=@{implementation='pinned external sftp-server.exe';authenticatedBatchPassed=$true} + Write-Stage 'sftp-preflight-complete' + Write-Stage 'production-route-start' + & $ProductionRouteProbe $name $port $clientKey $known + $report.productionRoute='passed-authenticated-cleanup' + Write-Stage 'production-route-complete' + } + $report.status='proof-passed-cleanup-pending' +} catch { + $report.status='failed';$report.error=$_.Exception.Message +} finally { + try { + Record-PrivateServiceDiagnostics + Write-Stage 'cleanup-start' + Write-Stage 'cleanup-service-query-start' + $privateService=Get-CimInstance Win32_Service -Filter "Name='$serviceName'" + Write-Stage 'cleanup-service-query-complete' + if($createdService -and $privateService -and ($privateService.PathName -notlike "*$root*" -or ($ownedServerPid -and $privateService.ProcessId -and $privateService.ProcessId -ne $ownedServerPid))){throw 'Private service identity changed; refuse stop'} + Write-Stage 'cleanup-child-accounting-start' + $rows=@(Get-CimInstance Win32_Process) + $ownedChildren=@($rows | Where-Object {$_.ExecutablePath -and $_.ExecutablePath.StartsWith($sshDir+'\',[StringComparison]::OrdinalIgnoreCase)}) + $report.childrenBeforeStop=@($ownedChildren | ForEach-Object {@{pid=$_.ProcessId;parentPid=$_.ParentProcessId;created=$_.CreationDate.ToUniversalTime().ToString('o');image=[IO.Path]::GetFileName($_.ExecutablePath)}}) + Write-Stage 'cleanup-child-accounting-complete' + Write-Stage 'cleanup-service-stop-delete-start' + if($createdService){Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue;Invoke-Bounded sc.exe @('delete',$serviceName) | Out-Null} + Write-Stage 'cleanup-service-stop-delete-complete' + Write-Stage 'cleanup-process-exit-start' + $exitDeadline=[DateTime]::UtcNow.AddSeconds(10) + while($ownedServerPid -and (Get-Process -Id $ownedServerPid -ErrorAction SilentlyContinue) -and [DateTime]::UtcNow -lt $exitDeadline){Start-Sleep -Milliseconds 100} + if($ownedServerPid -and (Get-Process -Id $ownedServerPid -ErrorAction SilentlyContinue)){throw 'Private sshd process still live; no PID-only kill attempted'} + Write-Stage 'cleanup-process-exit-complete' + Write-Stage 'cleanup-service-absence-start' + $serviceDeadline=[DateTime]::UtcNow.AddSeconds(10) + while($createdService -and (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) -and [DateTime]::UtcNow -lt $serviceDeadline){Start-Sleep -Milliseconds 100} + if($createdService -and (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)){throw 'Private service still registered'} + Write-Stage 'cleanup-service-absence-complete' + Record-PrivateServiceDiagnostics -AfterStop + Write-Stage 'cleanup-child-exit-start' + $childDeadline=[DateTime]::UtcNow.AddSeconds(10) + do { + $remaining=@(Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and $_.ExecutablePath.StartsWith($sshDir+'\',[StringComparison]::OrdinalIgnoreCase)}) + if(-not $remaining.Count){break};Start-Sleep -Milliseconds 200 + } while([DateTime]::UtcNow -lt $childDeadline) + $report.childrenAfterStop=@($remaining | ForEach-Object {@{pid=$_.ProcessId;parentPid=$_.ParentProcessId;created=$_.CreationDate.ToUniversalTime().ToString('o');image=[IO.Path]::GetFileName($_.ExecutablePath)}}) + Write-Stage 'cleanup-child-exit-complete' + if($remaining.Count){throw 'Private SSH child processes remain; preserve files and discard ephemeral runner'} + Write-Stage 'cleanup-user-profile-start' + if($sid){ + $profileWait=[Diagnostics.Stopwatch]::StartNew() + do { + $profiles=@(Get-CimInstance Win32_UserProfile | Where-Object SID -eq $sid) + if(-not @($profiles | Where-Object Loaded).Count){break} + Start-Sleep -Milliseconds 500 + } while($profileWait.Elapsed.TotalSeconds -lt 30) + $report.profileUnloadWaitMs=$profileWait.ElapsedMilliseconds + $report.privateProfile=@($profiles | ForEach-Object {@{loaded=$_.Loaded;status=$_.Status}}) + Write-Stage 'cleanup-user-profile-observed' + $loadedProfiles=@($profiles | Where-Object Loaded) + $report.profileCleanup=if($loadedProfiles.Count){'Loaded profile retained for disposable CI VM destruction'}else{'Unloaded profile removed'} + $profiles | Where-Object {-not $_.Loaded} | Remove-CimInstance + } + Write-Stage 'cleanup-user-profile-complete' + Write-Stage 'cleanup-user-start' + if($createdUser){Remove-LocalUser -Name $name;if(Get-LocalUser -Name $name -ErrorAction SilentlyContinue){throw 'Private account still exists'}} + Write-Stage 'cleanup-user-complete' + Write-Stage 'cleanup-private-files-start' + Remove-Item -LiteralPath $root -Recurse -Force + Write-Stage 'cleanup-private-files-complete' + if($report.status -eq 'proof-passed-cleanup-pending'){$report.status='passed'} + $report.cleanup=@('private service stopped/deleted','owned sshd exit verified','private account removed; profile disposition recorded separately','private keys removed') + } catch {$report.status='failed';$report.cleanup=@('cleanup unverifiable; discard ephemeral runner');$report.cleanupError=$_.Exception.Message} + Write-Stage 'finished' +} +if($report.status -ne 'passed'){throw 'Native OpenSSH qualification failed; inspect sanitized receipt'} 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 new file mode 100644 index 00000000000..12e5f01b0c6 --- /dev/null +++ b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 @@ -0,0 +1,17 @@ +$ErrorActionPreference='Stop' +$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'} +$definition=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Diagnostic-ExitStatuses'},$true) +. ([scriptblock]::Create($definition.Extent.Text)) +function Assert-Statuses([string]$Text,[long[]]$Expected){ + $actual=@(Diagnostic-ExitStatuses $Text) + if(($actual -join ',') -ne ($Expected -join ',')){throw 'Unexpected numeric diagnostic'} +} +Assert-Statuses "debug1: Exit status 3221225781`nclient secret path /private/id" @(3221225781) +Assert-Statuses 'CreateProcess error: 5; exit code -1073741515' @(5,-1073741515) +Assert-Statuses 'identity key-123, host 127.0.0.1 port 65000' @() +Assert-Statuses ('x'*16384+' exit status 123') @() +Assert-Statuses (('exit status 7; '*20)) @(7,7,7,7,7,7,7,7) +# Extract functions through the AST: never provision the fixture while testing diagnostics. +'PASS: fixture parse and five numeric-diagnostic cases' diff --git a/config/ci/windows-ssh-provider/verify-bun-output.mjs b/config/ci/windows-ssh-provider/verify-bun-output.mjs new file mode 100644 index 00000000000..295041909ea --- /dev/null +++ b/config/ci/windows-ssh-provider/verify-bun-output.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve, join } from 'node:path' +import { pathToFileURL } from 'node:url' + +export const SOURCE = '744846f844374847c902b5e7fd59b4342a51ef99' +export const PRODUCER = '34d1c11c67cbd1a653da1d07796daf7a1b0f2a7d' +export const PRODUCT = '2084c58ba5410106ce61153a9fb16cdb4b6e5301' +export const PATCH = '276f475c90c6761c58b9f56b3f4bfafa079d0c29c5861b23d2320c9ff39c35fb' +export const SUCCESS = 'Both patched Bun Windows targets built with network disabled. Native Windows behavior, signatures and production runtime promotion remain unqualified.' +const digest = file => createHash('sha256').update(readFileSync(file)).digest('hex') +const json = file => JSON.parse(readFileSync(file, 'utf8')) + +export function verifyProducer(run, runId) { + assert.equal(String(run.id), String(runId)) + assert.equal(run.repository?.full_name, 'stablyai/orca') + assert.equal(run.status, 'completed') + assert.equal(run.conclusion, 'success') + assert.equal(run.head_sha, PRODUCER) + assert.equal(run.head_branch, 'OrcaWin/np-full-offline-bun-diagnostic') + assert.equal(run.path, '.github/workflows/diagnostic-full-offline-bun.yml') + assert.equal(run.event, 'push') +} + +export function verifyStableBuildOptions(root) { + const receipts = {} + for (const arch of ['x64', 'aarch64']) { + const argsPath = join(root, 'results', `${arch}-args.txt`) + const args = readFileSync(argsPath, 'utf8').trim().split(/\r?\n/) + assert.deepEqual(args, ['--profile=release', '--canary=false', '--os=windows', + `--arch=${arch}`, '--lto=off', `--build-dir=build/conpty-${arch}`, '-j2', + ...(arch === 'x64' ? ['--baseline=true'] : [])], 'Unexpected effective build arguments') + const optionsPath = join(root, 'results', `${arch}-build-options.rs`) + const options = readFileSync(optionsPath, 'utf8') + for (const [name, declaration] of Object.entries({ + IS_CANARY: 'pub const IS_CANARY: bool = false;', + SHA: `pub const SHA: &str = "${SOURCE}";`, + BASE_PATH: 'pub const BASE_PATH: &[u8] = "/work/source".as_bytes();', + CODEGEN_PATH: `pub const CODEGEN_PATH: &[u8] = "/work/source/build/conpty-${arch}/codegen".as_bytes();` + })) { + const matches = options.split(/\r?\n/).filter(line => line.includes(`pub const ${name}:`)) + assert.deepEqual(matches, [declaration], `Unexpected effective ${name}`) + } + assert.match(options, /pub const VERSION: crate::Version = crate::Version \{\r?\n major: 1,\r?\n minor: 4,\r?\n patch: 2,\r?\n\};/) + receipts[arch] = { argsSha256: digest(argsPath), optionsSha256: digest(optionsPath) } + } + return receipts +} + +export function verifyOutput(root, productRoot, arch) { + assert.ok(['x64', 'arm64'].includes(arch)) + assert.equal(readFileSync(join(root, 'results/SUCCESS'), 'utf8').trim(), SUCCESS) + assert.equal(json(join(root, 'work/source-cache-receipt.json')).source, SOURCE) + assert.equal(digest(join(root, 'results/applied.patch')), PATCH) + const container = json(join(root, 'results/container.json')) + assert.equal(container.length, 1) + assert.equal(container[0].HostConfig.NetworkMode, 'none') + assert.equal(container[0].HostConfig.NanoCpus, 2_000_000_000) + assert.equal(container[0].HostConfig.Memory, 8 * 1024 ** 3) + assert.equal(container[0].HostConfig.PidsLimit, 512) + const buildOptions = verifyStableBuildOptions(root) + const rows = readFileSync(join(root, 'results/output.sha256'), 'utf8').trim().split(/\r?\n/) + assert.equal(rows.length, 2) + const hashes = new Map() + for (const row of rows) { + const match = /^([a-f0-9]{64}) (bun-windows-(?:x64|aarch64)\.exe)$/.exec(row) + assert.ok(match, 'Malformed output hash') + assert.ok(!hashes.has(match[2]), 'Duplicate output hash') + hashes.set(match[2], match[1]) + } + const require = createRequire(join(productRoot, 'package.json')) + const { readPeMachine, PE_MACHINE } = require('./config/scripts/windows-pe-machine.cjs') + for (const [target, filename] of [['x64', 'bun-windows-x64.exe'], ['arm64', 'bun-windows-aarch64.exe']]) { + const file = join(root, 'results', filename) + assert.equal(digest(file), hashes.get(filename), 'Runtime digest mismatch') + assert.equal(readPeMachine(file), PE_MACHINE[target], 'Runtime architecture mismatch') + } + const name = `bun-windows-${arch === 'arm64' ? 'aarch64' : arch}.exe` + return { binary: resolve(root, 'results', name), sha256: hashes.get(name), architecture: arch, buildOptions } +} + +export async function verifyPackage(candidate, productRoot) { + assert.equal(digest(join(productRoot, 'out/orcad/bun-runtime.exe')), candidate.sha256, + 'Package substituted another runtime') + const { WINDOWS_CONPTY_FILES } = await import(pathToFileURL(join(productRoot, 'src/shared/windows-conpty-release.ts'))) + const require = createRequire(join(productRoot, 'package.json')) + const { readPeMachine, PE_MACHINE } = require('./config/scripts/windows-pe-machine.cjs') + const companions = {} + for (const [name, expected] of Object.entries(WINDOWS_CONPTY_FILES[candidate.architecture])) { + const file = join(productRoot, 'out/orcad/conpty', name) + assert.equal(digest(file), expected, 'ConPTY digest mismatch') + assert.equal(readPeMachine(file), PE_MACHINE[candidate.architecture]) + companions[name] = expected + } + return { ...candidate, companions, conptyLibrary: resolve(productRoot, 'out/orcad/conpty/conpty.dll') } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const [mode, receiptRoot, productRoot, arch, outputFile, runId] = process.argv.slice(2) + verifyProducer(json(join(receiptRoot, 'producer-run.json')), runId) + const candidate = verifyOutput(receiptRoot, resolve(productRoot), arch) + const result = mode === 'package' ? await verifyPackage(candidate, resolve(productRoot)) : candidate + assert.ok(mode === 'candidate' || mode === 'package') + writeFileSync(outputFile, JSON.stringify({ producerRun: runId, producer: PRODUCER, source: SOURCE, + product: PRODUCT, patch: PATCH, ...result }, null, 2) + '\n') +} diff --git a/config/ci/windows-ssh-provider/windows-arm-provider-route.test.ts b/config/ci/windows-ssh-provider/windows-arm-provider-route.test.ts new file mode 100644 index 00000000000..28a04d86d1d --- /dev/null +++ b/config/ci/windows-ssh-provider/windows-arm-provider-route.test.ts @@ -0,0 +1,560 @@ +import { createHash, randomUUID } from 'node:crypto' +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { expect, it, vi } from 'vitest' + +// Only Electron artifact discovery is substituted; SSH/deployment/leases stay real. +vi.mock('electron', () => ({ + app: { getAppPath: () => process.cwd(), getPath: () => process.env.ORCA_SSH_PROBE_STATE } +})) +import { ORCAD_BUN_RELEASE_ASSETS } from '../../shared/orcad-bun-runtime' +import { SshConnection } from './ssh-connection' +import { decodeRemotePowerShellScript, powerShellCommand } from './ssh-remote-powershell' +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { readWindowsProcessTableFresh } from '../windows/windows-process-table' +import { installCandidateOverride } from './windows-provider-candidate' +import { proveRepaint } from './windows-provider-repaint' +import { inspectProviderImages } from './windows-provider-loaded-images' +import { RELAY_WINDOWS_CONPTY_FILENAMES } from '../../shared/relay-artifacts' + +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Invalid RPC object') + return Object.fromEntries(Object.entries(value)) +} +function textField(value: Record, key: string): string { + const text = value[key] + if (typeof text !== 'string' || !text) throw new Error(`Missing ${key}`) + return text +} +function diagnosticErrorText(output: string): string { + const serialized = [...output.matchAll(/]*\bS="Error"[^>]*>([^<]*)<\/S>/gu)] + const text = output.trimStart().startsWith('#< CLIXML') + ? serialized + .map((match) => + match[1] + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') + .replace(/_x([0-9a-f]{4})_/gi, (_, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)) + ) + ) + .join('') + : output + // PowerShell source/location echoes contain command text, not failure evidence. + return text + .replace(/^[ \t]*\+[ \t]*(CategoryInfo|FullyQualifiedErrorId)[ \t]*:/gmu, '$1:') + .split(/\r?\n/) + .filter((line) => !/^\s*(?:\+|At (?:line:|.+:line\s))/u.test(line)) + .join('\n') +} +function structuredCimFailure(output: string): Record { + const identifiers = [ + 'MI RESULT 2', + 'MI RESULT 5', + 'MI RESULT 6', + 'MI RESULT 7', + 'HRESULT 0x80070005', + 'HRESULT 0x80041003', + 'AccessDenied', + 'PermissionDenied' + ] as const + const identifier = + /^\s*FullyQualifiedErrorId\s*:\s*([^,\r\n]+),Microsoft\.Management\.Infrastructure\.CimCmdlets\.InvokeCimMethodCommand\s*$/mu.exec( + output + )?.[1] + const category = + /^\s*CategoryInfo\s*:\s*(PermissionDenied|NotSpecified|InvalidOperation|ResourceUnavailable|ObjectNotFound)\s*:/mu.exec( + output + )?.[1] + const identifierHresult = /^HRESULT (0x[0-9a-f]{8})$/iu.exec(identifier ?? '')?.[1] + const codes: Record = identifierHresult + ? { hresult: Number(identifierHresult) } + : {} + for (const match of output.matchAll( + /\b(HResult|NativeErrorCode|ErrorCode)\s*[:=]\s*(0x[0-9a-f]{1,8}|-?\d{1,10})(?![\da-z])/giu + )) { + const key = + match[1].toLowerCase() === 'hresult' + ? 'hresult' + : match[1].toLowerCase() === 'nativeerrorcode' + ? 'nativeErrorCode' + : 'errorCode' + codes[key] = Number(match[2]) + } + return { + ...(identifier && identifiers.some((known) => known === identifier) + ? { cimErrorId: identifier } + : {}), + ...(category ? { powerShellCategory: category } : {}), + ...codes + } +} +function classifyFailure(error: unknown): Record { + const message = error instanceof Error && error.message.length <= 262144 ? error.message : '' + // execCommand includes the command before the output; never classify that script as an error. + const commandFailure = /^Command "([\s\S]*)" failed \(exit (-?\d{1,10})\): ([\s\S]*)$/.exec( + message + ) + const output = diagnosticErrorText( + (commandFailure?.[3] ?? (message.startsWith('Command "') ? '' : message)).slice(0, 16384) + ) + let command = '' + try { + command = decodeRemotePowerShellScript(commandFailure?.[1] ?? '') + } catch { + /* Malformed diagnostics have no command phase. */ + } + const wmiCreateFailure = /Win32_Process\.Create failed with (\d{1,10})/.exec(output) + const commandPhase = + command.includes('--spawn-detached') || command.includes('--detached') + ? 'detached-launch' + : command.includes('--connect') + ? 'relay-connect' + : command.includes('--version') + ? 'runtime-version' + : commandFailure + ? 'remote-command' + : undefined + const categories = [ + ['sftp', /sftp|subsystem/i], + ['permission', /permission|access(?: is)? denied|EACCES|EPERM/i], + ['missing-file', /not.found|ENOENT|missing/i], + ['timeout', /timed?.?out|timeout/i], + ['runtime', /bun|runtime/i], + ['integrity', /hash|sha256|integrity/i], + ['connection', /connection|channel|socket/i], + ['mock-contract', /not a function|mock/i], + ['command-not-found', /not recognized|command not found/i], + ['invalid-executable', /not a valid win32|bad exe|invalid image/i], + ['syntax', /syntax error|unexpected token|ParserError/i], + ['sharing-violation', /being used by another process|sharing violation/i], + ['cim', /CimException|Invoke-CimMethod|Win32_Process\.Create/i] + ] as const + return { + ...structuredCimFailure(output), + ...(error && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'number' && + Number.isInteger(error.code) + ? { nativeExitCode: error.code } + : {}), + ...(wmiCreateFailure ? { wmiCreateReturnCode: Number(wmiCreateFailure[1]) } : {}), + ...(commandFailure + ? { + remoteExitCode: Number(commandFailure[2]), + commandPhase, + outputCharacters: output.length, + outputLines: output + .split(/\r?\n/) + .slice(0, 12) + .map((line, index) => ({ + index, + categories: categories + .filter(([, pattern]) => pattern.test(line)) + .map(([label]) => label) + })) + } + : {}), + kind: + error instanceof Error + ? error.constructor.name.replace(/[^a-zA-Z0-9_]/g, '').slice(0, 64) + : typeof error, + categories: categories.filter(([, pattern]) => pattern.test(output)).map(([label]) => label), + frames: + error instanceof Error + ? [ + ...(error.stack ?? '') + .split('\n') + .filter((line) => /^\s+at /u.test(line)) + .slice(0, 8) + .join('\n') + .slice(0, 16384) + .matchAll(/([a-zA-Z0-9_-]+\.(?:ts|js|mjs|cjs)):(\d+):(\d+)/g) + ] + .slice(0, 8) + .map((match) => `${match[1]}:${match[2]}:${match[3]}`) + : [] + } +} +it('retains numeric remote failure evidence without leaking command or output text', () => { + const result = classifyFailure( + new Error( + 'Command "bun.exe --detached secret-token permission-test" failed (exit 5): Access is denied.\nC:\\Users\\secret-user' + ) + ) + expect(result).toMatchObject({ + remoteExitCode: 5, + commandPhase: 'detached-launch', + categories: ['permission'], + outputLines: [ + { index: 0, categories: ['permission'] }, + { index: 1, categories: [] } + ] + }) + expect(JSON.stringify(result)).not.toMatch(/secret|bun.exe|permission-test|Users/) + const scriptOnly = classifyFailure( + new Error('Command "bun.exe permission check" failed (exit 2): Nothing classified') + ) + expect(scriptOnly).toMatchObject({ remoteExitCode: 2, categories: [] }) +}) +it('preserves the numeric WMI creation verdict', () => { + expect( + classifyFailure( + new Error('Command "bun --detached" failed (exit 1): Win32_Process.Create failed with 2') + ) + ).toMatchObject({ + remoteExitCode: 1, + wmiCreateReturnCode: 2, + commandPhase: 'detached-launch', + categories: ['cim'] + }) +}) +it('decodes compressed command phase without classifying its private command text', () => { + const command = powerShellCommand( + 'bun.exe --detached secret-token permission-test; ' + '# private\n'.repeat(1000) + ) + const result = classifyFailure( + new Error(`Command "${command}" failed (exit 1): Nothing classified`) + ) + expect(result).toMatchObject({ commandPhase: 'detached-launch', categories: [] }) + expect(JSON.stringify(result)).not.toMatch(/secret|private|bun.exe|permission-test/) +}) +it('extracts only allowlisted CIM metadata from serialized PowerShell errors', () => { + const output = + '#< CLIXML\nInvoke-CimMethod : Access is denied._x000D__x000A_' + + 'At line:1 char:2_x000D__x000A_+ bun.exe --detached secret-token_x000D__x000A_' + + '+ CategoryInfo : PermissionDenied: (Win32_Process:String) [Invoke-CimMethod], CimException_x000D__x000A_' + + '+ FullyQualifiedErrorId : HRESULT 0x80070005,Microsoft.Management.Infrastructure.CimCmdlets.InvokeCimMethodCommand_x000D__x000A_' + + 'HResult: -2147024891_x000D__x000A_NativeErrorCode: 5_x000D__x000A_' + + 'bun.exe private secret-token' + const result = classifyFailure(new Error(`Command "private" failed (exit 1): ${output}`)) + expect(result).toMatchObject({ + categories: ['permission', 'cim'], + cimErrorId: 'HRESULT 0x80070005', + powerShellCategory: 'PermissionDenied', + hresult: -2147024891, + nativeErrorCode: 5 + }) + expect(result).not.toHaveProperty('wmiCreateReturnCode') + expect(JSON.stringify(result)).not.toMatch( + /secret|private|bun.exe|Win32_Process|InvokeCimMethodCommand/ + ) +}) +it('does not retain unknown identifiers, paths, numeric source echoes or incomplete XML', () => { + const output = + '+ secret-token HResult: 123\n' + + 'FullyQualifiedErrorId : C:\\Users\\secret-token,Microsoft.Management.Infrastructure.CimCmdlets.InvokeCimMethodCommand\n' + + 'CategoryInfo : secret-token: unknown\nHResult: 0x80041003\nNativeErrorCode: 12345678901234' + const result = classifyFailure(new Error(output)) + expect(result).toMatchObject({ hresult: 2147749891 }) + expect(result).not.toHaveProperty('cimErrorId') + expect(result).not.toHaveProperty('powerShellCategory') + expect(result).not.toHaveProperty('nativeErrorCode') + const unknownHresult = classifyFailure( + new Error( + 'FullyQualifiedErrorId : HRESULT 0x80041001,Microsoft.Management.Infrastructure.CimCmdlets.InvokeCimMethodCommand' + ) + ) + expect(unknownHresult).toMatchObject({ hresult: 2147749889 }) + expect(unknownHresult).not.toHaveProperty('cimErrorId') + expect(JSON.stringify(result)).not.toMatch(/secret|Users/) + expect(classifyFailure(new Error('#< CLIXML\npermission'))).toMatchObject({ + categories: [] + }) + expect( + classifyFailure(new Error('Command "private" failed (exit 1): ' + 'x'.repeat(262144))) + ).toMatchObject({ categories: [] }) +}) +const configPath = process.env.ORCA_SSH_PROBE_CONFIG +it( + 'native ARM OpenSSH candidate provider preserves all settled rows and shell state', + { timeout: 600_000 }, + async () => { + if (!configPath || !process.env.ORCA_SSH_PROBE_STATE) + throw new Error('Explicit private SSH fixture configuration is required') + expect(process.platform).toBe('win32') + expect(process.arch).toBe('arm64') + const config = record(JSON.parse(readFileSync(configPath!, 'utf8'))) + const source = textField(config, 'sourceCommit') + expect(source).toMatch(/^[a-f0-9]{40}$/) + // CI wrapper verifies git HEAD before starting this process and writes immutable receipt. + expect(textField(config, 'observedSourceCommit')).toBe(source) + const port = config.port + if (typeof port !== 'number' || !Number.isInteger(port)) + throw new Error('Invalid private SSH port') + const instance = `arm-native-${randomUUID()}` + const createConnection = (): SshConnection => + new SshConnection( + { + id: instance, + label: instance, + host: '127.0.0.1', + port, + username: textField(config, 'username'), + identityFile: textField(config, 'identityFile'), + identitiesOnly: true, + source: 'manual' + }, + { + onStateChange: () => {}, + onCredentialRequest: async () => { + throw new Error('Interactive auth forbidden') + } + } + ) + let conn = createConnection() + const hello = { protocolVersion: 1, clientInstanceId: instance, requestedRole: 'session-owner' } + let mux: SshChannelMultiplexer | undefined + let grant: Record | undefined + const terminals = new Set() + let daemon: { pid: number; creationTimeMs?: number } | undefined + const shellIdentities: { pid: number; creationTimeMs: number }[] = [] + let output = '' + const fixtureDirectories: string[] = [] + let remoteRelayDirectory = '' + let deployedRuntime = '' + let candidate: ReturnType | undefined + let deploymentStarted = false + let stage = 'connecting' + const stages: string[] = [] + const receipts: Record = { source, instance, cleanupVerified: false, stages } + const deploy = async (): Promise => { + deploymentStarted = true + stage = 'deployment' + const allowedProgress = [ + 'Detecting remote platform...', + 'Checking existing relay...', + 'Uploading relay...', + 'Installing native dependencies...', + 'Starting relay...' + ] + const result = await deployAndLaunchRelay( + conn, + (status) => { + if (allowedProgress.includes(status)) { + stage = status + if (stages.length < 32) stages.push(status) + } + }, + 60, + instance + ) + stage = 'artifact-and-runtime-identity' + expect(result.platform).toBe('win32-arm64') + expect(result.nodePath?.toLowerCase()).toContain('bun') + if (!result.remoteRelayDir) throw new Error('Missing actual remote relay directory') + const artifactHashes: Record = {} + for (const filename of [ + 'relay.js', + 'parcel-watcher.node', + 'windows-process-tree.node', + ...RELAY_WINDOWS_CONPTY_FILENAMES + ]) { + const expected = createHash('sha256') + .update(readFileSync(join(process.cwd(), 'out', 'relay', 'win32-arm64', filename))) + .digest('hex') + const actual = createHash('sha256') + .update(readFileSync(join(result.remoteRelayDir, filename))) + .digest('hex') + expect(actual).toBe(expected) + artifactHashes[filename] = actual + } + receipts.deployedArtifacts = artifactHashes + const runtimePath = result.nodePath + if (!runtimePath) throw new Error('Missing actual runtime executable') + const runtimeHash = createHash('sha256').update(readFileSync(runtimePath)).digest('hex') + expect(runtimeHash).toBe(ORCAD_BUN_RELEASE_ASSETS['win32-arm64'].executableSha256) + remoteRelayDirectory = result.remoteRelayDir + deployedRuntime = runtimePath + receipts.runtime = { + path: runtimePath, + sha256: runtimeHash, + expectedSourcePin: false, + candidateOverride: true + } + if (!result.credentialFile) throw new Error('Deployment omitted private credential identity') + const nextMux = new SshChannelMultiplexer(result.transport) + mux = nextMux + nextMux.onDispose(() => { + if (mux === nextMux) mux = undefined + }) + mux.onNotificationByMethod('pty.data', (message) => { + if (typeof message.data === 'string') output = (output + message.data).slice(-65536) + }) + const rows = await readWindowsProcessTableFresh() + const normalizeCommand = (value: string): string => value.replaceAll('\\', '/').toLowerCase() + const owners = rows.filter( + (row) => + normalizeCommand(row.command).includes(normalizeCommand(result.credentialFile!)) && + row.command.includes('--detached') + ) + expect(owners).toHaveLength(1) + expect(owners[0].name.toLowerCase()).toBe('bun.exe') + const command = normalizeCommand(owners[0].command) + const executable = normalizeCommand(runtimePath) + expect( + command.startsWith('\"' + executable + '\" ') || command.startsWith(executable + ' ') + ).toBe(true) + const descendants = new Set([owners[0].pid]) + for (let changed = true; changed;) { + changed = false + for (const row of rows) + if (descendants.has(row.ppid) && !descendants.has(row.pid)) { + descendants.add(row.pid) + changed = true + } + } + expect( + rows.filter((row) => descendants.has(row.pid) && row.name.toLowerCase() === 'node.exe') + ).toEqual([]) + receipts.hostNodeExclusion = { + relayImage: 'bun.exe', + descendantSnapshotHasNode: false, + scope: 'daemon and live descendants at deployment/reconnect snapshots' + } + if (!owners[0].creationTimeMs) throw new Error('Daemon process identity unverifiable') + if (daemon) expect(owners[0]).toMatchObject(daemon) + daemon = { pid: owners[0].pid, creationTimeMs: owners[0].creationTimeMs } + receipts.daemon = daemon + const admitted = record( + await mux.request('pty.openClient', { + ...hello, + ...(grant + ? { resume: { ownerGeneration: grant.ownerGeneration, ownerLease: grant.ownerLease } } + : {}) + }) + ) + if (grant) expect(admitted.resumed).toBe(true) + if (!('ownerGeneration' in admitted) || typeof admitted.ownerLease !== 'string') + throw new Error('Missing lease') + grant = admitted + } + const spawn = async (): Promise => { + const result = record( + await mux!.request('pty.spawn', { cols: 80, rows: 24, shellOverride: 'powershell.exe' }) + ) + const id = textField(result, 'id') + terminals.add(id) + return id + } + try { + candidate = installCandidateOverride(config, process.env.ORCA_SSH_PROBE_STATE) + receipts.candidateAdmission = candidate.receipt + await conn.connect() + await deploy() + const id = await spawn() + const nonce = randomUUID().replaceAll('-', '') + output = '' + mux!.notify('pty.data', { + id, + data: `$orcaProbe='${nonce}'; Write-Output ('READY_' + $orcaProbe + '_' + $PID)\r` + }) + await expect.poll(() => output, { timeout: 30000 }).toContain(`READY_${nonce}_`) + const before = output.match(new RegExp(`READY_${nonce}_(\\d+)`))?.[1] + expect(before).toBeTruthy() + const shell = (await readWindowsProcessTableFresh()).find((row) => row.pid === Number(before)) + if (!shell?.creationTimeMs) throw new Error('Shell identity unverifiable') + shellIdentities.push({ pid: shell.pid, creationTimeMs: shell.creationTimeMs }) + mux!.dispose('connection_lost') + mux = undefined + await conn.disconnect() + conn = createConnection() + await conn.connect() + await deploy() + await mux!.request('pty.attach', { id }) + output = '' + mux!.notify('pty.data', { id, data: "Write-Output ('AFTER_' + $orcaProbe + '_' + $PID)\r" }) + await expect.poll(() => output, { timeout: 30000 }).toContain(`AFTER_${nonce}_${before}`) + receipts.sameShellState = true + stage = 'provider-resize-and-loaded-images' + receipts.repaint = await proveRepaint({ + mux: mux!, + runtime: deployedRuntime, + fixtureParent: dirname(remoteRelayDirectory), + receiptPath: join(dirname(textField(config, 'receiptPath')), 'repaint-events.json'), + ownTerminal: (terminalId) => { + terminals.add(terminalId) + }, + ownDirectory: (directory) => { + fixtureDirectories.push(directory) + }, + observeProvider: async () => { + if (!daemon) throw new Error('Missing owned daemon identity') + const evidence = await inspectProviderImages(daemon, remoteRelayDirectory) + shellIdentities.push(...evidence.descendantIdentities) + return evidence + } + }) + const fresh = await spawn() + output = '' + mux!.notify('pty.data', { id: fresh, data: "Write-Output ('FRESH_' + $PID)\r" }) + await expect.poll(() => output, { timeout: 30000 }).toMatch(/FRESH_\d+/) + const freshPid = Number(output.match(/FRESH_(\d+)/)?.[1]) + const freshShell = (await readWindowsProcessTableFresh()).find((row) => row.pid === freshPid) + if (!freshShell?.creationTimeMs) throw new Error('Fresh shell identity unverifiable') + shellIdentities.push({ pid: freshShell.pid, creationTimeMs: freshShell.creationTimeMs }) + await mux!.request('pty.shutdown', { id: fresh, immediate: true }) + terminals.delete(fresh) + } catch (error) { + receipts.primaryFailure = { stage, ...classifyFailure(error) } + throw new Error('Production SSH route failed; inspect sanitized primaryFailure receipt') + } finally { + try { + if (!mux && daemon) { + await conn.disconnect() + conn = createConnection() + await conn.connect() + await deploy() + } + if (mux) { + for (const id of terminals) await mux.request('pty.shutdown', { id, immediate: true }) + expect(await mux.request('pty.listProcesses', {})).toEqual([]) + mux.dispose() + mux = undefined + } + await conn.disconnect() + if (daemon) + await expect + .poll( + async () => { + const rows = await readWindowsProcessTableFresh() + return rows.some( + (row) => row.pid === daemon!.pid && row.creationTimeMs === daemon!.creationTimeMs + ) + }, + { timeout: 90000, interval: 1000 } + ) + .toBe(false) + for (const shell of shellIdentities) { + const rows = await readWindowsProcessTableFresh() + expect( + rows.some((row) => row.pid === shell.pid && row.creationTimeMs === shell.creationTimeMs) + ).toBe(false) + } + receipts.cleanupVerified = Boolean(daemon) || !deploymentStarted + if (!receipts.cleanupVerified) + throw new Error('Deployment may have launched an unobserved relay; cleanup unverifiable') + for (const directory of fixtureDirectories) + rmSync(directory, { recursive: true, force: true }) + } catch (error) { + receipts.cleanupFailure = classifyFailure(error) + throw error + } finally { + candidate?.restore() + receipts.sourcePinRestored = true + mux?.dispose() + try { + await conn.disconnect() + } finally { + writeFileSync(textField(config, 'receiptPath'), JSON.stringify(receipts, null, 2)) + } + } + } + } +) diff --git a/config/ci/windows-ssh-provider/windows-provider-candidate.ts b/config/ci/windows-ssh-provider/windows-provider-candidate.ts new file mode 100644 index 00000000000..348646d1680 --- /dev/null +++ b/config/ci/windows-ssh-provider/windows-provider-candidate.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { copyFileSync, mkdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { ORCAD_BUN_RELEASE_ASSETS, ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' + +export function installCandidateOverride(config: Record, state: string) { + const receiptPath = config.candidateReceiptPath + const receiptHash = config.candidateReceiptSha256 + assert( + typeof receiptPath === 'string' && + typeof receiptHash === 'string' && + /^[a-f0-9]{64}$/.test(receiptHash) + ) + const bytes = readFileSync(receiptPath) + assert.equal( + createHash('sha256').update(bytes).digest('hex'), + receiptHash, + 'preverified admission receipt changed' + ) + const receipt = JSON.parse(bytes.toString('utf8')) + assert.equal(receipt.producer, '34d1c11c67cbd1a653da1d07796daf7a1b0f2a7d') + assert.equal(receipt.source, '744846f844374847c902b5e7fd59b4342a51ef99') + assert.equal(receipt.patch, '276f475c90c6761c58b9f56b3f4bfafa079d0c29c5861b23d2320c9ff39c35fb') + assert.equal(receipt.product, '2084c58ba5410106ce61153a9fb16cdb4b6e5301') + assert.equal(String(receipt.producerRun), '36503596770') + assert.equal(receipt.architecture, 'arm64') + assert(typeof receipt.binary === 'string' && /^[a-f0-9]{64}$/.test(receipt.sha256)) + assert.equal( + createHash('sha256').update(readFileSync(receipt.binary)).digest('hex'), + receipt.sha256 + ) + const cache = join(state, 'orcad-artifacts', 'bun', `v${ORCAD_BUN_VERSION}`, 'win32-arm64') + mkdirSync(cache, { recursive: true }) + copyFileSync(receipt.binary, join(cache, 'bun-runtime.exe')) + const original = ORCAD_BUN_RELEASE_ASSETS['win32-arm64'].executableSha256 + ORCAD_BUN_RELEASE_ASSETS['win32-arm64'].executableSha256 = receipt.sha256 + return { + receipt: { + ...receipt, + receiptSha256: receiptHash, + candidateOverride: true, + originalExecutablePin: original, + expectedSourcePin: false, + scope: 'diagnostic process only; private cache; production deployment validation retained' + }, + restore: () => { + ORCAD_BUN_RELEASE_ASSETS['win32-arm64'].executableSha256 = original + } + } +} diff --git a/config/ci/windows-ssh-provider/windows-provider-loaded-images.ts b/config/ci/windows-ssh-provider/windows-provider-loaded-images.ts new file mode 100644 index 00000000000..dad17816a8b --- /dev/null +++ b/config/ci/windows-ssh-provider/windows-provider-loaded-images.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync, realpathSync } from 'node:fs' +import { join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { readWindowsProcessTableFresh } from '../windows/windows-process-table' +import { WINDOWS_CONPTY_FILES } from '../../shared/windows-conpty-release' + +export async function inspectProviderImages( + daemon: { pid: number; creationTimeMs?: number }, + relayDirectory: string +) { + assert(Number.isInteger(daemon.pid) && daemon.pid > 0 && daemon.creationTimeMs) + const before = await readWindowsProcessTableFresh() + assert( + before.some((row) => row.pid === daemon.pid && row.creationTimeMs === daemon.creationTimeMs) + ) + const descendants = new Set([daemon.pid]) + for (let changed = true; changed;) { + changed = false + for (const row of before) { + if (descendants.has(row.ppid) && !descendants.has(row.pid)) { + descendants.add(row.pid) + changed = true + } + } + } + const consoles = before.filter( + (row) => descendants.has(row.pid) && row.name.toLowerCase() === 'openconsole.exe' + ) + assert(consoles.length > 0, 'no owned OpenConsole descendant') + for (const row of consoles) + assert(row.creationTimeMs, 'OpenConsole creation identity unavailable') + // Scoped module/image query only; process enumeration uses the existing native table. + const script = `$ErrorActionPreference='Stop'; $daemon=[Diagnostics.Process]::GetProcessById(${daemon.pid}); $modules=@($daemon.Modules | Where-Object {$_.ModuleName -ieq 'conpty.dll'} | ForEach-Object {$_.FileName}); $images=@(${consoles.map((row) => row.pid).join(',')} | ForEach-Object { $p=[Diagnostics.Process]::GetProcessById($_); @{pid=$p.Id;path=$p.MainModule.FileName;creationTimeMs=([DateTimeOffset]$p.StartTime.ToUniversalTime()).ToUnixTimeMilliseconds()} }); @{modules=$modules;images=$images} | ConvertTo-Json -Depth 4 -Compress` + const result = await runProcess({ + program: join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ), + args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + timeoutMs: 15000, + env: { ORCA_BACKGROUND_LAUNCH: '1' }, + maxOutputBytes: 65536 + }) + assert(!result.timedOut && result.code === 0, 'scoped provider module inspection failed') + const evidence = JSON.parse(result.stdout) + assert(Array.isArray(evidence.modules) && evidence.modules.length === 1) + const samePath = (a: string, b: string) => + realpathSync(a).toLowerCase() === realpathSync(b).toLowerCase() + assert( + samePath(evidence.modules[0], join(relayDirectory, 'conpty.dll')), + 'relay loaded foreign ConPTY' + ) + assert(Array.isArray(evidence.images) && evidence.images.length === consoles.length) + for (const image of evidence.images) { + const original = consoles.find((row) => row.pid === image.pid) + assert( + original?.creationTimeMs && Math.abs(original.creationTimeMs - image.creationTimeMs) <= 1, + 'console identity changed during query' + ) + assert( + samePath(image.path, join(relayDirectory, 'OpenConsole.exe')), + 'foreign OpenConsole image' + ) + assert.equal( + createHash('sha256').update(readFileSync(image.path)).digest('hex'), + WINDOWS_CONPTY_FILES.arm64['OpenConsole.exe'] + ) + } + assert.equal( + createHash('sha256').update(readFileSync(evidence.modules[0])).digest('hex'), + WINDOWS_CONPTY_FILES.arm64['conpty.dll'] + ) + const after = await readWindowsProcessTableFresh() + for (const original of [daemon, ...consoles]) { + assert( + after.some( + (row) => row.pid === original.pid && row.creationTimeMs === original.creationTimeMs + ), + 'owner changed during module observation' + ) + } + const descendantIdentities = before + .filter((row) => descendants.has(row.pid) && row.pid !== daemon.pid) + .map((row) => { + assert(typeof row.creationTimeMs === 'number', 'descendant cleanup identity unavailable') + return { pid: row.pid, creationTimeMs: row.creationTimeMs } + }) + return { ...evidence, providerHashes: WINDOWS_CONPTY_FILES.arm64, daemon, descendantIdentities } +} diff --git a/config/ci/windows-ssh-provider/windows-provider-repaint.ts b/config/ci/windows-ssh-provider/windows-provider-repaint.ts new file mode 100644 index 00000000000..3125e7fcf6d --- /dev/null +++ b/config/ci/windows-ssh-provider/windows-provider-repaint.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { readWrappedLineGlyphs } from '../daemon/__fixtures__/terminal-wide-cell-grid' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { powerShellLiteral } from './ssh-remote-powershell' + +// Exact fixture and oracle from pty-repaint-wide-char-buffer.bun.test.ts. +const korean = '안녕하세요 오르카 테스트입니다. 결론부터 말씀드리면 시각적 피로도' +const latin = 'roadmap/complete-overhaul-backlog-history.md (1.75) R-08)' +type Event = { data: string } | { resizeTo: number } +class ProbeEmulator extends HeadlessEmulator { + lines(): string[] { + return readWrappedLineGlyphs(this.terminal).filter((line) => line.length > 0) + } +} +function assertEight(events: Event[]): string[] { + const emulator = new ProbeEmulator({ cols: 40, rows: 12 }) + try { + for (const event of events) { + if ('resizeTo' in event) emulator.resize(event.resizeTo, 12) + else emulator.writeSync(event.data) + } + const lines = emulator.lines() + const ko = korean.replace(/\s+/g, '') + const la = latin.replace(/\s+/g, '') + assert.equal( + lines.filter((line) => line === ko).length, + 8, + 'all eight Korean rows must survive' + ) + assert.equal(lines.filter((line) => line === la).length, 8, 'all eight Latin rows must survive') + assert.deepEqual( + lines.filter((line) => line !== ko && line !== la), + [], + 'unexpected fixture rows' + ) + return lines + } finally { + emulator.dispose() + } +} +export async function proveRepaint(options: { + mux: SshChannelMultiplexer + runtime: string + fixtureParent: string + receiptPath: string + ownTerminal(id: string): void + ownDirectory(directory: string): void + observeProvider(): Promise +}): Promise> { + const directory = join(options.fixtureParent, `provider-probe-${randomUUID()}`) + mkdirSync(directory) + options.ownDirectory(directory) + const script = join(directory, 'repaint.cjs') + const settled = join(directory, 'settled') + const dimensions = join(directory, 'dimensions') + const source = `const fs=require('node:fs');process.stdout.write('\\x1bc');let i=0;const timer=setInterval(()=>{process.stdout.write(${JSON.stringify(korean)}+'\\r\\n'+${JSON.stringify(latin)}+'\\r\\n');if(++i===8){clearInterval(timer);process.stdout.write('',()=>fs.writeFileSync(${JSON.stringify(settled)},''));}},25);setInterval(()=>fs.writeFileSync(${JSON.stringify(dimensions)},String(process.stdout.columns)),20);setTimeout(()=>process.exit(0),45000);` + writeFileSync(script, source) + const events: Event[] = [] + let id = '' + let lastOutput = performance.now() + let overflow = false + let bytes = 0 + const subscription = options.mux.onNotificationByMethod('pty.data', (message) => { + if (message.id === id && typeof message.data === 'string') { + bytes += Buffer.byteLength(message.data) + if (events.length > 10000 || bytes > 1048576) { + overflow = true + return + } + events.push({ data: message.data }) + lastOutput = performance.now() + } + }) + const quiet = async (cols: number) => { + const deadline = performance.now() + 15000 + while (performance.now() < deadline) { + assert(!overflow, 'Repaint event budget exceeded') + if ( + existsSync(settled) && + existsSync(dimensions) && + readFileSync(dimensions, 'utf8') === String(cols) && + performance.now() - lastOutput > 250 + ) + return + await delay(25) + } + throw new Error('Fixture did not settle') + } + try { + const spawned: unknown = await options.mux.request('pty.spawn', { + cols: 40, + rows: 12, + shellOverride: 'powershell.exe' + }) + assert( + spawned && typeof spawned === 'object' && 'id' in spawned && typeof spawned.id === 'string' + ) + id = spawned.id + options.ownTerminal(id) + options.mux.notify('pty.data', { + id, + data: `& ${powerShellLiteral(options.runtime)} --no-env-file --config=NUL --no-install ${powerShellLiteral(script)}\r` + }) + await quiet(40) + const initial = assertEight(events) + const provider = await options.observeProvider() + const settledWidths: { cols: number; lines: string[] }[] = [{ cols: 40, lines: initial }] + for (const cols of [31, 47]) { + events.push({ resizeTo: cols }) + options.mux.notify('pty.resize', { id, cols, rows: 12 }) + // Give the remote resize time to arrive before measuring output quiescence. + await delay(350) + await quiet(cols) + settledWidths.push({ cols, lines: assertEight(events) }) + } + const final = assertEight(events) + return { + initial, + final, + resizeOrder: [40, 31, 47], + settledWidths, + provider, + fixtureDirectory: directory, + koreanRows: 8, + latinRows: 8, + exactRowsOnly: true + } + } finally { + subscription() + writeFileSync( + options.receiptPath, + JSON.stringify({ directory, terminalId: id, events }, null, 2) + ) + } +}