diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index c081831c28b..bb5bdba37ae 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -81,7 +81,20 @@ env: ADHOC_RETAIN_DAYS: 30 jobs: + # Why its own job: this package ships relays for Windows SSH hosts, and only a + # Windows runner compiles the addon that launches one outside sshd's job. + # Why the unvetted ref is safe here: this job holds no secrets, and the mac job + # vets the same ref before it downloads anything, so fork code never gets signed. + relay-windows-process-tree: + if: github.repository == 'stablyai/orca' + permissions: + contents: read + uses: ./.github/workflows/relay-windows-process-tree.yml + with: + ref: ${{ inputs.ref || github.ref_name }} + build-adhoc-mac: + needs: relay-windows-process-tree if: github.repository == 'stablyai/orca' # Why an environment: it gives the signing/notary/App secrets somewhere to # live that a stale copy of this workflow on an old branch cannot reach. @@ -259,6 +272,14 @@ jobs: fi cat "$RUNNER_TEMP/adhoc-identity.txt" >>"$GITHUB_OUTPUT" + # Only a Windows runner compiles these; the relay-windows-process-tree job + # built them for this run. + - name: Collect the Windows process-table addons for the relay + uses: actions/download-artifact@v8 + with: + name: relay-windows-process-tree + path: .build/windows-process-tree + - name: Build app run: pnpm build:release env: @@ -267,6 +288,9 @@ jobs: # gate accepts only 'stable' or 'rc', so leaving this unset keeps them # silent, which is correct for unvetted branch artifacts. ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + # Fail the build rather than ship a relay that cannot launch outside + # sshd's job for a standard user on a Windows SSH host. + ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 # Why the token is minted here and not at the top: installation tokens live # one hour, everything before this point writes nothing, and the notary round diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 4e89b1f5a4b..788ea873481 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -70,7 +70,20 @@ env: DAILY_RETAIN_COUNT: 30 jobs: + # Why its own job: this package ships relays for Windows SSH hosts, and only a + # Windows runner compiles the addon that launches one outside sshd's job. + # Why main and not the mac job's head_sha: that is resolved inside the mac job. + # A commit or two of drift is harmless; build-relay rejects a stale or wrong-arch addon. + relay-windows-process-tree: + if: github.repository == 'stablyai/orca' + permissions: + contents: read + uses: ./.github/workflows/relay-windows-process-tree.yml + with: + ref: main + build-daily-mac: + needs: relay-windows-process-tree if: github.repository == 'stablyai/orca' outputs: tag: ${{ steps.release.outputs.tag }} @@ -247,6 +260,15 @@ jobs: fi cat "$RUNNER_TEMP/daily-identity.txt" >>"$GITHUB_OUTPUT" + # Only a Windows runner compiles these; the relay-windows-process-tree job + # built them for this run. + - name: Collect the Windows process-table addons for the relay + if: steps.freshness.outputs.should_build == 'true' + uses: actions/download-artifact@v8 + with: + name: relay-windows-process-tree + path: .build/windows-process-tree + - name: Build app if: steps.freshness.outputs.should_build == 'true' run: pnpm build:release @@ -256,6 +278,9 @@ jobs: # gate accepts only 'stable' or 'rc', so leaving this unset keeps them # silent, which is correct for unvetted dev artifacts. ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + # Fail the build rather than ship a relay that cannot launch outside + # sshd's job for a standard user on a Windows SSH host. + ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 # Why a second mint: everything from here on writes to the daily repo, and # the notary round trip inside the publish step can be tens of minutes. The diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index 8e061b83563..30c06c5eaa1 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -116,9 +116,22 @@ jobs: echo "main moved to $head_sha (last hourly built $last_sha); building." fi - build-hourly-mac: + # Why its own job: this package ships relays for Windows SSH hosts, and only a + # Windows runner compiles the addon that launches one outside sshd's job. + relay-windows-process-tree: needs: preflight if: needs.preflight.outputs.should_build == 'true' + permissions: + contents: read + uses: ./.github/workflows/relay-windows-process-tree.yml + with: + ref: ${{ needs.preflight.outputs.head_sha }} + + build-hourly-mac: + needs: + - preflight + - relay-windows-process-tree + if: needs.preflight.outputs.should_build == 'true' outputs: tag: ${{ steps.release.outputs.tag }} version: ${{ steps.hourly.outputs.version }} @@ -254,6 +267,14 @@ jobs: fi cat "$RUNNER_TEMP/hourly-identity.txt" >>"$GITHUB_OUTPUT" + # Only a Windows runner compiles these; the relay-windows-process-tree job + # built them for this run. + - name: Collect the Windows process-table addons for the relay + uses: actions/download-artifact@v8 + with: + name: relay-windows-process-tree + path: .build/windows-process-tree + - name: Build app run: pnpm build:release env: @@ -262,6 +283,9 @@ jobs: # gate accepts only 'stable' or 'rc', so leaving this unset keeps them # silent, which is correct for unvetted dev artifacts. ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + # Fail the build rather than ship a relay that cannot launch outside + # sshd's job for a standard user on a Windows SSH host. + ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 # Why a second mint: everything from here on writes to the hourly repo, and # the notary round trip inside the publish step can be tens of minutes. The diff --git a/.github/workflows/relay-windows-process-tree.yml b/.github/workflows/relay-windows-process-tree.yml new file mode 100644 index 00000000000..4aadd22a692 --- /dev/null +++ b/.github/workflows/relay-windows-process-tree.yml @@ -0,0 +1,71 @@ +name: Relay Windows process-tree addons + +# Why a reusable workflow: every desktop package ships relays for Windows SSH hosts, +# but only a Windows runner can compile the addon that launches a relay outside +# sshd's job. macOS and Linux packaging jobs download this artifact into +# .build/windows-process-tree before `pnpm build:release` stages it. +on: + workflow_call: + inputs: + ref: + description: Commit or tag to build; must match what the packaging job checks out. + required: true + type: string + +permissions: + contents: read + +jobs: + build: + # Why windows-2022: windows-latest moved to VS 2026 before node-gyp could detect + # it. GitHub-hosted, so release-cut's SignPath provenance rule still holds. + runs-on: windows-2022 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + # Plain setup rather than a composite action: `uses: ./` resolves from the + # checked-out ref, which may be a release tag that predates the action. + - name: Setup pnpm + uses: pnpm/setup@v2 + with: + install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + # Why host-only: the addon is compiled from the patched source pnpm + # materializes, and arm64 cross-compiles through node-gyp --arch. + - name: Install dependencies + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + # The build script refuses unpatched source and checks each output's PE + # machine, ReadProcessMemory import, and spawnOutsideJob export. + - name: Build Windows process-table addons for the relay + shell: bash + run: | + node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64 + node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64 + + - name: Upload relay addons + uses: actions/upload-artifact@v7 + with: + name: relay-windows-process-tree + path: .build/windows-process-tree/ + if-no-files-found: error + retention-days: 7 + # A rerun attempt re-uploads under the same name, which is otherwise refused. + overwrite: true diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index b8315436d5e..4228b31fd21 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1184,12 +1184,25 @@ jobs: - name: Confirm blocking release gates passed run: echo "All blocking release gates passed; artifact builds may start." + # Why its own job: every desktop package ships Windows relays, but only a + # Windows runner compiles the addon that launches one outside sshd's job. + # Needs only cut, so it overlaps the release gates instead of extending them. + relay-windows-process-tree: + needs: cut + if: needs.cut.outputs.should_release == 'true' + permissions: + contents: read + uses: ./.github/workflows/relay-windows-process-tree.yml + with: + ref: refs/tags/${{ needs.cut.outputs.tag }} + build: needs: - cut - create-release - orcad-template - release-preflight + - relay-windows-process-tree if: needs.cut.outputs.should_release == 'true' env: # beforePack and afterPack fail the package when the template is absent. @@ -1398,19 +1411,15 @@ jobs: echo "identity=$identity" >>"$GITHUB_OUTPUT" echo "Classified $TAG as $identity" - # Why here and not in build:relay: only a Windows runner can compile it, and - # arm64 cross-compiles from this same x64 agent. Mirrors dev-channel-win-build.yml, - # which had it while release-cut did not — so every stable installer through - # v1.4.203 shipped Windows relays with no windows-process-tree.node, silently - # falling back to the PowerShell scan on every Windows SSH host. - # Why no run_attempt guard, unlike the artifact steps below: Build app is ungated, - # so a rerun would reach the required-addon check with nothing staged and fail. - - name: Build Windows process-table addon for the relay - if: matrix.platform == 'win' - shell: bash - run: | - node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64 - node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64 + # Why every leg: each package ships relays for Windows SSH hosts. v1.4.203 and + # earlier shipped Windows relays with no windows-process-tree.node, and mac and + # Linux packages shipped none until the addon moved to its own Windows job. + # Why no run_attempt guard: Build app is ungated, so a rerun needs it staged too. + - name: Collect the Windows process-table addons for the relay + uses: actions/download-artifact@v8 + with: + name: relay-windows-process-tree + path: .build/windows-process-tree # Why an explicit step rather than trusting the runner image: the packaged # CLI launcher is a native Rust binary, and a Windows host without cargo @@ -1448,9 +1457,9 @@ jobs: ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }} ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} - # Fail the release rather than ship a relay that silently falls back to - # the PowerShell scan on every Windows SSH host. - ORCA_REQUIRE_RELAY_NATIVE_ADDONS: ${{ matrix.platform == 'win' && 'x64,arm64' || '' }} + # Fail the release rather than ship a relay that cannot launch outside + # sshd's job for a standard user on a Windows SSH host. + ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. - name: Download the orcad deployment template @@ -2295,6 +2304,8 @@ jobs: - create-release - orcad-template - release-preflight + # release-mac-build.yml downloads the relay addons from this run. + - relay-windows-process-tree if: needs.cut.outputs.should_release == 'true' # Why: SignPath requires every job in this signing workflow to be # GitHub-hosted. The actual mac build runs in release-mac-build.yml so diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 6cd660a0018..b8555ffbdeb 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -31,6 +31,10 @@ jobs: # faster runner. runs-on: blacksmith-6vcpu-macos-15 timeout-minutes: 60 + # actions: read fetches the relay addons from the release-cut run. + permissions: + actions: read + contents: write env: NODE_OPTIONS: --max-old-space-size=4096 steps: @@ -129,6 +133,16 @@ jobs: echo "identity=$identity" >>"$GITHUB_OUTPUT" echo "Classified $TAG as $identity" + # Only a Windows runner compiles these; release-cut's relay-windows-process-tree + # job built them from this tag before dispatching this workflow. + - name: Collect the Windows process-table addons for the relay + uses: actions/download-artifact@v8 + with: + name: relay-windows-process-tree + path: .build/windows-process-tree + run-id: ${{ inputs.release_run_id }} + github-token: ${{ github.token }} + - name: Build app run: pnpm build:release env: @@ -138,6 +152,9 @@ jobs: ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }} ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }} + # Fail the release rather than ship a relay that cannot launch outside + # sshd's job for a standard user on a Windows SSH host. + ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64 # Design D2: the parent release-cut run merged it from every node-server lane at this tag. - name: Download the orcad deployment template from the release run diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 95bde6bbb5c..fc427e28940 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -25,6 +25,11 @@ on: - 'config/scripts/orcad-windows-process-tree.mjs' - '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-staging.mjs' + - 'config/scripts/windows-process-tree-gyp-rebuild.mjs' + - 'src/shared/relay-windows-breakaway-launch.ts' - '!src/**/*.test.ts' - 'src/main/ssh/ssh-relay-windows-host-lane.test.ts' - 'config/ci/windows-ssh-provider/**' @@ -88,9 +93,14 @@ jobs: } & config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 # 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. - name: Build this runner's orcad slot, the win32 template and the relay shell: bash + env: + ORCA_REQUIRE_RELAY_NATIVE_ADDONS: ${{ matrix.arch }} run: | + node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${{ matrix.arch }} pnpm build:orcad-prebuilds pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" pnpm build:orcad-prebuilds --smoke diff --git a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 index 35e3563aeb1..e4bfae52d69 100644 --- a/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 +++ b/config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1 @@ -21,7 +21,6 @@ $priorKnown=if($knownExisted){[IO.File]::ReadAllBytes($knownPath)}else{$null} $priorBackground=$env:ORCA_BACKGROUND_LAUNCH $failed=[Collections.Generic.List[string]]::new() $summary=[Collections.Generic.List[hashtable]]::new() -$wmiOriginalSd=$null function Invoke-PrivateSsh([string]$Account,[string]$Command) { $start=[Diagnostics.ProcessStartInfo]::new($Context.sshExe) @@ -44,25 +43,16 @@ function Set-PrivateDefaultShell([string]$Shell) { } } -# The relay launch goes through WMI Win32_Process.Create, which WMI refuses to a standard user's SSH -# (network) logon without Remote Enable on root\cimv2. Prints ORCA_WMI= or ORCA_WMI=denied. +# Diagnostic only: Orca must launch the relay 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= +# 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''}"' $match=[regex]::Match($out,'ORCA_WMI=(\S+)') if($match.Success){return $match.Groups[1].Value}else{return 'no-output'} } -function Grant-CellWmiLaunch([string[]]$Sids) { - $sd=(Invoke-CimMethod -Namespace root/cimv2 -ClassName __SystemSecurity -MethodName GetSD).SD - $script:wmiOriginalSd=[byte[]]$sd - $raw=[Security.AccessControl.RawSecurityDescriptor]::new([byte[]]$sd,0) - # WBEM_ENABLE | WBEM_METHOD_EXECUTE | WBEM_REMOTE_ACCESS - foreach($sid in $Sids){$raw.DiscretionaryAcl.InsertAce(0,[Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]::None,[Security.AccessControl.AceQualifier]::AccessAllowed,0x23,[Security.Principal.SecurityIdentifier]::new($sid),$false,$null))} - $bytes=[byte[]]::new($raw.BinaryLength);$raw.GetBinaryForm($bytes,0) - $result=Invoke-CimMethod -Namespace root/cimv2 -ClassName __SystemSecurity -MethodName SetSD -Arguments @{SD=$bytes} - if($result.ReturnValue -ne 0){throw "WMI namespace grant failed with $($result.ReturnValue)"} -} - New-Item -ItemType Directory -Force -Path $ReceiptRoot | Out-Null Push-Location $SourceRoot try { @@ -73,15 +63,9 @@ try { Add-Content -LiteralPath $knownPath -Value ("`n"+[IO.File]::ReadAllText($Context.knownHosts)) $env:ORCA_BACKGROUND_LAUNCH='1' # DefaultShell is still stock cmd here. - $wmi=@{beforeGrant=(Test-PrivateWmiLaunch $Context.accounts[0].name);granted=$false} - if($wmi.beforeGrant -ne '0'){ - Write-Host "::warning::Standard SSH user cannot launch through WMI Win32_Process.Create ($($wmi.beforeGrant)); Orca's Windows relay launch needs it. Granting the cell accounts Remote Enable on root\cimv2 so the remaining assertions run." - Grant-CellWmiLaunch @($Context.accounts[0..($Cells.Count-1)] | ForEach-Object {(Get-LocalUser -Name $_.name).SID.Value}) - $wmi.granted=$true - $wmi.afterGrant=Test-PrivateWmiLaunch $Context.accounts[0].name - if($wmi.afterGrant -ne '0'){throw "WMI launch still refused after the grant ($($wmi.afterGrant))"} - } - $summary.Add(@{standardUserWmiLaunch=$wmi}) + $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)" + $summary.Add(@{standardUserWmiLaunch=$wmi;granted=$false}) for($index=0;$index -lt $Cells.Count;$index++){ $cell=$Cells[$index];$account=$Context.accounts[$index];$shell=$shells[$cell] Set-PrivateDefaultShell $shell @@ -112,7 +96,6 @@ try { } while([DateTime]::UtcNow -lt $graceDeadline) $summary.Add(@{relayProcessesAfterGrace=@($relays | ForEach-Object {[IO.Path]::GetFileName($_.ExecutablePath)})}) } finally { - if($wmiOriginalSd){$null=Invoke-CimMethod -Namespace root/cimv2 -ClassName __SystemSecurity -MethodName SetSD -Arguments @{SD=$wmiOriginalSd}} Set-PrivateDefaultShell 'cmd' if($knownExisted){[IO.File]::WriteAllBytes($knownPath,$priorKnown)}else{Remove-Item -LiteralPath $knownPath -Force -ErrorAction SilentlyContinue} $env:ORCA_BACKGROUND_LAUNCH=$priorBackground diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch index 3855e187ec6..bc50a8cfa63 100644 --- a/config/patches/@vscode__windows-process-tree@0.8.0.patch +++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch @@ -1,5 +1,5 @@ diff --git a/binding.gyp b/binding.gyp -index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..0bb2af7923b6e6f1f0da40cae8067304cd1fea14 100644 +index 11a5e71..1b2e74b 100644 --- a/binding.gyp +++ b/binding.gyp @@ -3,7 +3,6 @@ @@ -10,9 +10,13 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..0bb2af7923b6e6f1f0da40cae8067304 ], "conditions": [ ['OS=="win"', { -@@ -14,13 +13,12 @@ +@@ -12,15 +11,15 @@ + "src/cpu_worker.cc", + "src/process.cc", "src/process_worker.cc", - "src/process_commandline.cc" +- "src/process_commandline.cc" ++ "src/process_commandline.cc", ++ "src/process_launch.cc" ], - "include_dirs": [], + "include_dirs": ["deps/node-addon-api"], @@ -28,7 +32,7 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..0bb2af7923b6e6f1f0da40cae8067304 "/guard:cf", "/sdl", diff --git a/lib/index.js b/lib/index.js -index e586cd6ead522a4ff060f5338201f45e3467d639..0ee62c35558ff40e2298c464fdf6e9485f9d181d 100644 +index e586cd6..0ee62c3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -7,11 +7,14 @@ @@ -61,7 +65,7 @@ index e586cd6ead522a4ff060f5338201f45e3467d639..0ee62c35558ff40e2298c464fdf6e948 }); return buildNode(root, maxDepth); diff --git a/lib/index.ts b/lib/index.ts -index 7b53aad05c3682a5c7d814d81a39c3f391ae97e3..284dae185b56241244b4d6bcab9e08f7530b8cf3 100644 +index 7b53aad..284dae1 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,12 +6,16 @@ @@ -97,13 +101,21 @@ index 7b53aad05c3682a5c7d814d81a39c3f391ae97e3..284dae185b56241244b4d6bcab9e08f7 }); diff --git a/src/addon.cc b/src/addon.cc -index 5253960ad97496b53c4a02572b64b270302af22f..a801526e20af72b6442c769a8ba1640386c23f46 100644 +index 5253960..f146490 100644 --- a/src/addon.cc +++ b/src/addon.cc -@@ -50,9 +50,32 @@ +@@ -6,6 +6,7 @@ + #include + #include "cpu_worker.h" + #include "process_worker.h" ++#include "process_launch.h" + + void GetProcessList(const Napi::CallbackInfo& args) { + Napi::Env env(args.Env()); +@@ -50,9 +51,33 @@ worker->Queue(); } - + +Napi::Value ReadProcessCreationTime(const Napi::CallbackInfo& args) { + Napi::Env env(args.Env()); + if (args.Length() != 1 || !args[0].IsNumber()) { @@ -126,6 +138,7 @@ index 5253960ad97496b53c4a02572b64b270302af22f..a801526e20af72b6442c769a8ba16403 + exports.Set("getProcessCreationTime", Napi::Function::New(env, ReadProcessCreationTime)); exports.Set("getProcessList", Napi::Function::New(env, GetProcessList)); exports.Set("getProcessCpuUsage", Napi::Function::New(env, GetProcessCpuUsage)); ++ exports.Set("spawnOutsideJob", Napi::Function::New(env, SpawnOutsideJob)); + // Lets a caller prove THIS BINARY understands CREATIONTIME. The JS enum is + // patched source and says nothing about what the .node was compiled from. + exports.Set("supportedProcessDataFlags", @@ -134,10 +147,10 @@ index 5253960ad97496b53c4a02572b64b270302af22f..a801526e20af72b6442c769a8ba16403 } diff --git a/src/process.cc b/src/process.cc -index 3eea92077c4d1d433119361d5c432881859131e9..22a47421da919c76e2194280974d39c2287b098d 100644 +index ad63727..22a4742 100644 --- a/src/process.cc +++ b/src/process.cc -@@ -21,7 +21,8 @@ uint32_t GetRawProcessList(std::vector& process_info, +@@ -21,7 +21,8 @@ if (Process32First(snapshot_handle, &process_entry)) { do { if (process_entry.th32ProcessID != 0) { @@ -147,7 +160,7 @@ index 3eea92077c4d1d433119361d5c432881859131e9..22a47421da919c76e2194280974d39c2 pinfo.pid = process_entry.th32ProcessID; pinfo.ppid = process_entry.th32ParentProcessID; -@@ -33,23 +34,51 @@ uint32_t GetRawProcessList(std::vector& process_info, +@@ -33,23 +34,51 @@ GetProcessCommandLine(pinfo); } @@ -201,7 +214,7 @@ index 3eea92077c4d1d433119361d5c432881859131e9..22a47421da919c76e2194280974d39c2 if (hProcess == NULL) { return; -@@ -81,7 +110,8 @@ void GetCpuUsage(Cpu& cpu_info, bool first_pass) { +@@ -81,7 +110,8 @@ DWORD pid = cpu_info.pid; HANDLE hProcess; @@ -212,10 +225,10 @@ index 3eea92077c4d1d433119361d5c432881859131e9..22a47421da919c76e2194280974d39c2 if (hProcess == NULL) { return; diff --git a/src/process.h b/src/process.h -index 82f8e4bcfa742551e5d874a7632736a7611d7aa7..78d1d2c3b2360ed06fd624b4cb2f5042510f7a77 100644 +index 3c9b852..72e1648 100644 --- a/src/process.h +++ b/src/process.h -@@ -22,18 +22,22 @@ struct ProcessInfo { +@@ -22,18 +22,22 @@ DWORD ppid; DWORD memory; // Reported in bytes std::string commandLine; @@ -240,7 +253,7 @@ index 82f8e4bcfa742551e5d874a7632736a7611d7aa7..78d1d2c3b2360ed06fd624b4cb2f5042 #endif // SRC_PROCESS_H_ diff --git a/src/process_commandline.cc b/src/process_commandline.cc -index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3210c3cfd 100644 +index 716051d..25907c0 100644 --- a/src/process_commandline.cc +++ b/src/process_commandline.cc @@ -7,61 +7,119 @@ @@ -405,11 +418,173 @@ index ea822b120e8038a4803e34647042f08f4aaf5ca1..25907c0bf542bed6c72b1b462b19bcf3 + return StoreCommandLineUtf8(process_info, command_line->Buffer, + command_line->Length / sizeof(wchar_t)); } +diff --git a/src/process_launch.cc b/src/process_launch.cc +new file mode 100644 +index 0000000..e3141c5 +--- /dev/null ++++ b/src/process_launch.cc +@@ -0,0 +1,140 @@ ++// Orca: start a detached process outside the caller's job object. ++// ++// Win32-OpenSSH puts every session's shell in a job with KILL_ON_JOB_CLOSE, so ++// anything a session starts dies when the session ends. The same job carries ++// BREAKAWAY_OK, so CREATE_BREAKAWAY_FROM_JOB lets a standard user start a ++// process that outlives it -- which Node cannot ask for: libuv never passes it. ++ ++#include "process_launch.h" ++ ++#include ++#include ++#include ++ ++namespace { ++ ++class OwnedHandle { ++ public: ++ explicit OwnedHandle(HANDLE handle) : handle_(handle) {} ++ ~OwnedHandle() { ++ if (valid()) { ++ CloseHandle(handle_); ++ } ++ } ++ OwnedHandle(const OwnedHandle&) = delete; ++ OwnedHandle& operator=(const OwnedHandle&) = delete; ++ bool valid() const { return handle_ != nullptr && handle_ != INVALID_HANDLE_VALUE; } ++ HANDLE get() const { return handle_; } ++ ++ private: ++ HANDLE handle_; ++}; ++ ++std::wstring ToWide(const Napi::Value& value) { ++ const std::u16string text = value.As().Utf16Value(); ++ return std::wstring(text.begin(), text.end()); ++} ++ ++HANDLE OpenInheritable(const std::wstring& path, DWORD access, DWORD disposition) { ++ SECURITY_ATTRIBUTES attributes{}; ++ attributes.nLength = sizeof(attributes); ++ attributes.bInheritHandle = TRUE; ++ return CreateFileW(path.c_str(), access, ++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, ++ &attributes, disposition, FILE_ATTRIBUTE_NORMAL, nullptr); ++} ++ ++Napi::Object Failure(Napi::Env env, const char* reason, const char* step, DWORD code) { ++ Napi::Object result = Napi::Object::New(env); ++ result.Set("ok", false); ++ result.Set("reason", reason); ++ result.Set("step", step); ++ result.Set("code", static_cast(code)); ++ return result; ++} ++ ++} // namespace ++ ++// spawnOutsideJob(application, commandLine, cwd, stdoutPath, stderrPath) ++Napi::Value SpawnOutsideJob(const Napi::CallbackInfo& args) { ++ Napi::Env env(args.Env()); ++ if (args.Length() != 5) { ++ throw Napi::TypeError::New(env, "spawnOutsideJob expects five string arguments."); ++ } ++ for (size_t index = 0; index < args.Length(); index++) { ++ if (!args[index].IsString()) { ++ throw Napi::TypeError::New(env, "spawnOutsideJob expects five string arguments."); ++ } ++ } ++ const std::wstring application = ToWide(args[0]); ++ const std::wstring command_line = ToWide(args[1]); ++ const std::wstring cwd = ToWide(args[2]); ++ // CreateProcessW may write into the command-line buffer. ++ std::vector command_buffer(command_line.begin(), command_line.end()); ++ command_buffer.push_back(L'\0'); ++ ++ OwnedHandle input(OpenInheritable(L"NUL", GENERIC_READ, OPEN_EXISTING)); ++ if (!input.valid()) { ++ return Failure(env, "failed", "open-stdin", GetLastError()); ++ } ++ OwnedHandle output(OpenInheritable(ToWide(args[3]), GENERIC_WRITE, CREATE_ALWAYS)); ++ if (!output.valid()) { ++ return Failure(env, "failed", "open-stdout", GetLastError()); ++ } ++ OwnedHandle error_output(OpenInheritable(ToWide(args[4]), GENERIC_WRITE, CREATE_ALWAYS)); ++ if (!error_output.valid()) { ++ return Failure(env, "failed", "open-stderr", GetLastError()); ++ } ++ ++ // Inherit exactly these three: an inherited SSH channel pipe would hold the ++ // launching session open for the relay's whole life. ++ HANDLE inherited[3] = {input.get(), output.get(), error_output.get()}; ++ SIZE_T attribute_size = 0; ++ InitializeProcThreadAttributeList(nullptr, 1, 0, &attribute_size); ++ std::vector attribute_storage(attribute_size); ++ auto* attribute_list = ++ reinterpret_cast(attribute_storage.data()); ++ if (!InitializeProcThreadAttributeList(attribute_list, 1, 0, &attribute_size)) { ++ return Failure(env, "failed", "attribute-list", GetLastError()); ++ } ++ if (!UpdateProcThreadAttribute(attribute_list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, ++ inherited, sizeof(inherited), nullptr, nullptr)) { ++ const DWORD code = GetLastError(); ++ DeleteProcThreadAttributeList(attribute_list); ++ return Failure(env, "failed", "handle-list", code); ++ } ++ ++ STARTUPINFOEXW startup{}; ++ startup.StartupInfo.cb = sizeof(startup); ++ startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; ++ startup.StartupInfo.hStdInput = input.get(); ++ startup.StartupInfo.hStdOutput = output.get(); ++ startup.StartupInfo.hStdError = error_output.get(); ++ startup.lpAttributeList = attribute_list; ++ PROCESS_INFORMATION info{}; ++ // CREATE_NO_WINDOW keeps one hidden console for the relay's console ++ // children, as the cmd.exe that WMI used to start it did. ++ const DWORD flags = CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_PROCESS_GROUP | ++ CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT; ++ const BOOL created = CreateProcessW( ++ application.c_str(), command_buffer.data(), nullptr, nullptr, TRUE, flags, nullptr, ++ cwd.empty() ? nullptr : cwd.c_str(), &startup.StartupInfo, &info); ++ const DWORD create_error = created ? ERROR_SUCCESS : GetLastError(); ++ DeleteProcThreadAttributeList(attribute_list); ++ if (!created) { ++ BOOL caller_in_job = FALSE; ++ IsProcessInJob(GetCurrentProcess(), nullptr, &caller_in_job); ++ const bool denied = create_error == ERROR_ACCESS_DENIED && caller_in_job; ++ return Failure(env, denied ? "breakaway-denied" : "failed", "create-process", create_error); ++ } ++ BOOL child_in_job = FALSE; ++ IsProcessInJob(info.hProcess, nullptr, &child_in_job); ++ CloseHandle(info.hThread); ++ CloseHandle(info.hProcess); ++ ++ Napi::Object result = Napi::Object::New(env); ++ result.Set("ok", true); ++ result.Set("pid", static_cast(info.dwProcessId)); ++ result.Set("inJob", child_in_job != FALSE); ++ return result; ++} +diff --git a/src/process_launch.h b/src/process_launch.h +new file mode 100644 +index 0000000..88872a1 +--- /dev/null ++++ b/src/process_launch.h +@@ -0,0 +1,10 @@ ++// Orca: start a detached process outside the caller's job object. ++ ++#ifndef SRC_PROCESS_LAUNCH_H_ ++#define SRC_PROCESS_LAUNCH_H_ ++ ++#include ++ ++Napi::Value SpawnOutsideJob(const Napi::CallbackInfo& args); ++ ++#endif // SRC_PROCESS_LAUNCH_H_ diff --git a/src/process_worker.cc b/src/process_worker.cc -index c9e3457a759c1acaa2644231a4917d45aed951f8..3f26a354477f062b34bd31fbd17be529e6a2fd7a 100644 +index f59559d..1d61c87 100644 --- a/src/process_worker.cc +++ b/src/process_worker.cc -@@ -43,6 +43,11 @@ void GetProcessesWorker::OnOK() { +@@ -43,6 +43,11 @@ Napi::String::New(env, pinfo.commandLine)); } @@ -422,10 +597,10 @@ index c9e3457a759c1acaa2644231a4917d45aed951f8..3f26a354477f062b34bd31fbd17be529 } diff --git a/typings/windows-process-tree.d.ts b/typings/windows-process-tree.d.ts -index 70e242b123e76d43c452007be1ce92a6d224c8bb..b1d0a53c0c18cc16531b394c19bb256bdbaf782f 100644 +index 70e242b..b1d0a53 100644 --- a/typings/windows-process-tree.d.ts +++ b/typings/windows-process-tree.d.ts -@@ -7,8 +7,17 @@ +@@ -7,9 +7,18 @@ export enum ProcessDataFlag { None = 0, Memory = 1, @@ -433,7 +608,7 @@ index 70e242b123e76d43c452007be1ce92a6d224c8bb..b1d0a53c0c18cc16531b394c19bb256b + CommandLine = 2, + CreationTime = 4 } -+ + + /** + * The flag bits the compiled addon actually understands, or undefined off + * win32. `ProcessDataFlag` above is source; this is what the binary reports, @@ -441,9 +616,10 @@ index 70e242b123e76d43c452007be1ce92a6d224c8bb..b1d0a53c0c18cc16531b394c19bb256b + */ + export const supportedProcessDataFlags: number | undefined; + export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined; - ++ export interface IProcessInfo { pid: number; + ppid: number; @@ -24,6 +33,9 @@ * The string returned is at most 512 chars, strings exceeding this length are truncated. */ diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 4bc46cdc9f6..20dd1e0b17a 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -23,12 +23,15 @@ import { join } from 'node:path' import { RELAY_BUILD_PLATFORMS, RELAY_VERSION_FILENAME, - RELAY_WINDOWS_PROCESS_TREE_FILENAME, RELAY_OPENCODE_SQLITE_READER_FILENAME, relayOptionalArtifactFilenames, isWindowsRelayPlatform, relayArtifactFilenames } from '../../src/shared/relay-artifacts.ts' +import { + parseRequiredRelayAddonArches, + stageRelayWindowsProcessTreeAddon +} from './relay-windows-process-tree-staging.mjs' const __dirname = import.meta.dirname // Why: the script lives under config/scripts, so go two levels up to reach the repo root. @@ -79,38 +82,16 @@ const NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE = join( 'relay-assets', NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME ) -// Written by build-windows-process-tree-relay-addon.mjs, which only runs on a -// Windows machine. +// Written by build-windows-process-tree-relay-addon.mjs on Windows, or downloaded +// from CI's relay-windows-process-tree artifact on other OSes. const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree') -// Which Windows arches must have the addon, as a comma-separated list ('all' for -// every arch). Per-arch rather than a flag because arm64 needs the MSVC ARM64 -// cross toolset, an optional VS component: where it is absent that relay should -// fall back to the scan, not fail the release the x64 relay is riding on. -const REQUIRED_ADDON_ARCHES = (process.env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS ?? '') - .split(',') - .map((value) => value.trim()) - .filter(Boolean) - -function stageWindowsProcessTreeAddon(platform, outDir) { - if (!isWindowsRelayPlatform(platform)) { - return - } - const arch = platform.slice('win32-'.length) - const source = join(WINDOWS_PROCESS_TREE_BUILD_DIR, arch, RELAY_WINDOWS_PROCESS_TREE_FILENAME) - if (!existsSync(source)) { - if (REQUIRED_ADDON_ARCHES.includes(arch) || REQUIRED_ADDON_ARCHES.includes('all')) { - throw new Error( - `Relay ${platform} needs ${source}. Run: node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${arch} (Windows only).` - ) - } - console.log( - `Relay ${platform}: no ${RELAY_WINDOWS_PROCESS_TREE_FILENAME}; relay will use the PowerShell scan.` - ) - return - } - copyFileSync(source, join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME)) -} +// Per-arch rather than a flag because arm64 needs the MSVC ARM64 cross toolset, +// an optional VS component: where it is absent that relay should fall back to +// the scan, not fail the release the x64 relay is riding on. +const REQUIRED_ADDON_ARCHES = parseRequiredRelayAddonArches( + process.env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS +) // Why: lets the packaging contract test build into a temp tree instead of // clobbering a developer's out/relay or racing tests that read it. @@ -249,7 +230,12 @@ for (const platform of RELAY_BUILD_PLATFORMS) { NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE, join(outDir, NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME) ) - stageWindowsProcessTreeAddon(platform, outDir) + stageRelayWindowsProcessTreeAddon({ + platform, + outDir, + buildDir: WINDOWS_PROCESS_TREE_BUILD_DIR, + requiredArches: REQUIRED_ADDON_ARCHES + }) // Why: include a content hash so the deploy check detects code changes even // when RELAY_VERSION hasn't been bumped. Hashing the whole manifest means a diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs index cc2472587a0..bd810144b8a 100644 --- a/config/scripts/build-windows-process-tree-relay-addon.mjs +++ b/config/scripts/build-windows-process-tree-relay-addon.mjs @@ -28,6 +28,7 @@ import { inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, stageWindowsProcessTreeNodeAddonApiHeaders, + windowsProcessTreeAddonHasRelayLauncher, WINDOWS_PROCESS_TREE_PACKAGE_DIR as PACKAGE_DIR } from './windows-process-tree-gyp-rebuild.mjs' @@ -126,6 +127,21 @@ function assertPatchApplied() { ) } } + // Without the launcher a standard-user SSH host cannot start a relay that outlives the session. + const requiredLauncherSources = [ + ['binding.gyp', '"src/process_launch.cc"'], + ['src/addon.cc', 'exports.Set("spawnOutsideJob"'], + ['src/process_launch.cc', 'CREATE_BREAKAWAY_FROM_JOB'] + ] + for (const [relativePath, expected] of requiredLauncherSources) { + const filePath = join(PACKAGE_DIR, relativePath) + if (!existsSync(filePath) || !readFileSync(filePath, 'utf8').includes(expected)) { + throw new Error( + `${relativePath} does not contain the relay launcher patch (${expected}). ` + + 'Run pnpm install before building the relay addon.' + ) + } + } } function repairCreationTimeSources() { @@ -444,6 +460,12 @@ function main() { `Built binary is ${describePeMachine(machine)}, expected 0x${PE_MACHINE[arch].toString(16)} for ${arch}. ${cause}` ) } + if (!windowsProcessTreeAddonHasRelayLauncher(built)) { + throw new Error( + 'The built addon does not export spawnOutsideJob. A relay would fall back to WMI, ' + + 'which refuses to launch it for a standard user.' + ) + } mkdirSync(outDir, { recursive: true }) const staged = join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME) diff --git a/config/scripts/hourly-preflight-workflow.test.mjs b/config/scripts/hourly-preflight-workflow.test.mjs index 2bec40b329b..4d453f07103 100644 --- a/config/scripts/hourly-preflight-workflow.test.mjs +++ b/config/scripts/hourly-preflight-workflow.test.mjs @@ -62,7 +62,7 @@ describe('hourly build preflight', () => { expect( preflight.steps.find((step) => step.id === 'app_token').with['permission-contents'] ).toBe('read') - expect(build.needs).toBe('preflight') + expect(build.needs).toEqual(['preflight', 'relay-windows-process-tree']) expect(build.if).toBe("needs.preflight.outputs.should_build == 'true'") expect(build.steps.find((step) => step.name === 'Checkout').with.ref).toBe( build.outputs.head_sha diff --git a/config/scripts/orcad-template-release-workflow.test.mjs b/config/scripts/orcad-template-release-workflow.test.mjs index ced4e6bd629..87ea8570767 100644 --- a/config/scripts/orcad-template-release-workflow.test.mjs +++ b/config/scripts/orcad-template-release-workflow.test.mjs @@ -105,7 +105,11 @@ describe('orcad template release wiring (design D2)', () => { } const macSteps = releaseMac.jobs['build-mac'].steps - const macDownload = stepIndex(macSteps, (step) => step.uses === 'actions/download-artifact@v8') + // Why by name: the mac job also downloads the relay Windows process-tree addons. + const macDownload = stepIndex( + macSteps, + (step) => step.uses === 'actions/download-artifact@v8' && step.with?.name === 'orcad-template' + ) expect(macSteps[macDownload].with).toMatchObject({ name: 'orcad-template', path: 'out/orcad-template', diff --git a/config/scripts/relay-windows-process-tree-staging.mjs b/config/scripts/relay-windows-process-tree-staging.mjs new file mode 100644 index 00000000000..5abd1baf35a --- /dev/null +++ b/config/scripts/relay-windows-process-tree-staging.mjs @@ -0,0 +1,64 @@ +/** + * Stage the prebuilt `@vscode/windows-process-tree` addon into a Windows relay bundle. + * + * Every desktop package ships relays for every host OS, so a macOS or Linux build + * needs the addon a Windows job compiled: without it a Windows SSH host cannot + * launch the relay outside sshd's job as a standard user. Release builds list the + * arches they require; a local build without the addon ships the scan fallback. + */ +import { copyFileSync } from 'node:fs' +import { join } from 'node:path' +import { + RELAY_WINDOWS_PROCESS_TREE_FILENAME, + isWindowsRelayPlatform +} from '../../src/shared/relay-artifacts.ts' +import { relayWindowsProcessTreeAddonDefect } from './windows-process-tree-gyp-rebuild.mjs' + +/** `ORCA_REQUIRE_RELAY_NATIVE_ADDONS`: comma-separated arches, or `all`. */ +export function parseRequiredRelayAddonArches(value) { + return (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +} + +/** + * @param {{ + * platform: string, + * outDir: string, + * buildDir: string, + * requiredArches: string[], + * log?: (message: string) => void + * }} options + * @returns {'staged' | 'skipped' | 'not-windows'} + */ +export function stageRelayWindowsProcessTreeAddon({ + platform, + outDir, + buildDir, + requiredArches, + log = console.log +}) { + if (!isWindowsRelayPlatform(platform)) { + return 'not-windows' + } + const arch = platform.slice('win32-'.length) + const source = join(buildDir, arch, RELAY_WINDOWS_PROCESS_TREE_FILENAME) + const required = requiredArches.includes(arch) || requiredArches.includes('all') + const defect = relayWindowsProcessTreeAddonDefect(source, arch) + if (defect) { + const reason = `${source}: ${defect}` + if (required) { + throw new Error( + `Relay ${platform} needs ${RELAY_WINDOWS_PROCESS_TREE_FILENAME}, but ${reason}. ` + + `On Windows run: node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${arch}. ` + + 'CI builds on other OSes download it from the relay-windows-process-tree artifact.' + ) + } + // Never ship a defective binary: the scan fallback beats a relay that loads the wrong addon. + log(`Relay ${platform}: ${reason}; relay will use the PowerShell scan.`) + return 'skipped' + } + copyFileSync(source, join(outDir, RELAY_WINDOWS_PROCESS_TREE_FILENAME)) + return 'staged' +} diff --git a/config/scripts/relay-windows-process-tree-staging.test.mjs b/config/scripts/relay-windows-process-tree-staging.test.mjs new file mode 100644 index 00000000000..07477965d43 --- /dev/null +++ b/config/scripts/relay-windows-process-tree-staging.test.mjs @@ -0,0 +1,106 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + parseRequiredRelayAddonArches, + stageRelayWindowsProcessTreeAddon +} from './relay-windows-process-tree-staging.mjs' +import { relayWindowsProcessTreeAddonDefect } from './windows-process-tree-gyp-rebuild.mjs' + +const MACHINE = { x64: 0x8664, arm64: 0xaa64 } +const LAUNCHER = 'spawnOutsideJob\0' + +const roots = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function peImage(machine, body) { + const header = Buffer.alloc(0x90) + header.write('MZ') + header.writeUInt32LE(0x80, 0x3c) + header.write('PE\0\0', 0x80) + header.writeUInt16LE(machine, 0x84) + return Buffer.concat([header, Buffer.from(body, 'binary')]) +} + +function fixture(addons = {}) { + const root = mkdtempSync(join(tmpdir(), 'relay-process-tree-')) + roots.push(root) + const buildDir = join(root, 'build') + const outDir = join(root, 'out') + mkdirSync(outDir) + for (const [arch, bytes] of Object.entries(addons)) { + mkdirSync(join(buildDir, arch), { recursive: true }) + writeFileSync(join(buildDir, arch, 'windows-process-tree.node'), bytes) + } + const logs = [] + const stage = (platform, requiredArches = []) => + stageRelayWindowsProcessTreeAddon({ + platform, + outDir, + buildDir, + requiredArches, + log: (message) => logs.push(message) + }) + return { buildDir, outDir, logs, stage, staged: join(outDir, 'windows-process-tree.node') } +} + +describe('relay windows-process-tree addon check', () => { + it('accepts only a clean launcher build for the requested machine', () => { + const { buildDir } = fixture({ + x64: peImage(MACHINE.x64, `ntdll.dll\0${LAUNCHER}`), + arm64: peImage(MACHINE.x64, LAUNCHER) + }) + expect( + relayWindowsProcessTreeAddonDefect(join(buildDir, 'x64/windows-process-tree.node'), 'x64') + ).toBeNull() + expect( + relayWindowsProcessTreeAddonDefect(join(buildDir, 'arm64/windows-process-tree.node'), 'arm64') + ).toContain('machine 0x8664, not arm64') + }) + + it('rejects a pre-launcher build that is otherwise clean and loadable', () => { + const { buildDir } = fixture({ x64: peImage(MACHINE.x64, 'NtQueryInformationProcess\0') }) + expect( + relayWindowsProcessTreeAddonDefect(join(buildDir, 'x64/windows-process-tree.node'), 'x64') + ).toContain('does not export spawnOutsideJob') + }) + + it('rejects the unpatched command-line reader even when it has the launcher', () => { + const { buildDir } = fixture({ x64: peImage(MACHINE.x64, `ReadProcessMemory\0${LAUNCHER}`) }) + expect( + relayWindowsProcessTreeAddonDefect(join(buildDir, 'x64/windows-process-tree.node'), 'x64') + ).toContain('ReadProcessMemory') + }) +}) + +describe('staging the relay windows-process-tree addon', () => { + it('copies a valid addon into each Windows relay and skips other hosts', () => { + const addon = peImage(MACHINE.arm64, LAUNCHER) + const { stage, staged } = fixture({ arm64: addon }) + expect(stage('linux-x64', ['all'])).toBe('not-windows') + expect(existsSync(staged)).toBe(false) + expect(stage('win32-arm64', ['x64', 'arm64'])).toBe('staged') + expect(readFileSync(staged)).toEqual(addon) + }) + + it('degrades to the scan when an unrequired addon is missing or stale', () => { + const { stage, staged, logs } = fixture({ x64: peImage(MACHINE.x64, 'no launcher') }) + expect(stage('win32-x64')).toBe('skipped') + expect(stage('win32-arm64')).toBe('skipped') + expect(existsSync(staged)).toBe(false) + expect(logs.join('\n')).toMatch(/does not export spawnOutsideJob[\s\S]*is missing/) + }) + + it('fails a release build whose required addon is missing or stale', () => { + const { stage, staged } = fixture({ x64: peImage(MACHINE.x64, 'no launcher') }) + expect(() => stage('win32-x64', ['x64', 'arm64'])).toThrow(/spawnOutsideJob/) + expect(() => stage('win32-arm64', ['all'])).toThrow(/is missing/) + expect(existsSync(staged)).toBe(false) + }) + + it('reads the per-arch requirement list', () => { + expect(parseRequiredRelayAddonArches(undefined)).toEqual([]) + expect(parseRequiredRelayAddonArches(' x64, arm64 ,')).toEqual(['x64', 'arm64']) + }) +}) diff --git a/config/scripts/relay-windows-process-tree-workflow-contract.test.mjs b/config/scripts/relay-windows-process-tree-workflow-contract.test.mjs new file mode 100644 index 00000000000..fae539bb0eb --- /dev/null +++ b/config/scripts/relay-windows-process-tree-workflow-contract.test.mjs @@ -0,0 +1,97 @@ +// Every shipped desktop package carries Windows relays, so each must stage the +// launcher-capable process-tree addon, not only the Windows packages that can compile it. +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') +const readWorkflow = (name) => + parse(readFileSync(join(projectDir, '.github/workflows', name), 'utf8')) + +const ARTIFACT = 'relay-windows-process-tree' +const ADDON_WORKFLOW = './.github/workflows/relay-windows-process-tree.yml' +const BUILD_BOTH_ARCHES = [ + 'node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64', + 'node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64' +] + +// [workflow, packaging job, job that produces the artifact in the same run] +const DOWNLOADING_PACKAGERS = [ + ['release-cut.yml', 'build', 'relay-windows-process-tree'], + ['hourly-mac-build.yml', 'build-hourly-mac', 'relay-windows-process-tree'], + ['daily-mac-build.yml', 'build-daily-mac', 'relay-windows-process-tree'], + ['adhoc-mac-build.yml', 'build-adhoc-mac', 'relay-windows-process-tree'] +] + +function stepIndex(job, predicate, label) { + const index = job.steps.findIndex(predicate) + expect(index, label).toBeGreaterThanOrEqual(0) + return index +} + +function expectRequiredBeforeBuild(job, stagingIndex) { + const build = stepIndex( + job, + (step) => /pnpm (run )?build:release\b/.test(step.run ?? ''), + 'build' + ) + expect(stagingIndex).toBeLessThan(build) + expect(job.steps[build].env.ORCA_REQUIRE_RELAY_NATIVE_ADDONS).toBe('x64,arm64') +} + +const isDownload = (step) => + step.uses?.startsWith('actions/download-artifact@') && step.with?.name === ARTIFACT + +describe('relay Windows process-tree addon in every desktop package', () => { + it('builds both arches once on a GitHub-hosted Windows runner and uploads them', () => { + const job = readWorkflow('relay-windows-process-tree.yml').jobs.build + expect(job['runs-on']).toBe('windows-2022') + const build = job.steps.find((step) => step.name?.startsWith('Build Windows process-table')) + expect(build.run.trim().split('\n')).toEqual(BUILD_BOTH_ARCHES) + const upload = job.steps.find((step) => step.uses?.startsWith('actions/upload-artifact@')) + expect(upload.with).toMatchObject({ + name: ARTIFACT, + path: '.build/windows-process-tree/', + 'if-no-files-found': 'error' + }) + }) + + it.each(DOWNLOADING_PACKAGERS)( + '%s %s downloads the addons and requires them', + (workflowName, jobName, producer) => { + const { jobs } = readWorkflow(workflowName) + expect(jobs[producer].uses).toBe(ADDON_WORKFLOW) + expect([jobs[jobName].needs].flat()).toContain(producer) + const job = jobs[jobName] + const download = stepIndex(job, isDownload, 'download') + expect(job.steps[download].with.path).toBe('.build/windows-process-tree') + expectRequiredBeforeBuild(job, download) + } + ) + + it('builds the release addons from the tag the packages are cut from', () => { + const { jobs } = readWorkflow('release-cut.yml') + expect(jobs[ARTIFACT].with.ref).toBe('refs/tags/${{ needs.cut.outputs.tag }}') + // The mac build is a separate dispatched run that downloads from this one. + expect(jobs['build-mac'].needs).toContain(ARTIFACT) + }) + + it('has the dispatched mac release build download from the release-cut run', () => { + const job = readWorkflow('release-mac-build.yml').jobs['build-mac'] + expect(job.permissions).toMatchObject({ actions: 'read' }) + const download = stepIndex(job, isDownload, 'download') + expect(job.steps[download].with['run-id']).toBe('${{ inputs.release_run_id }}') + expectRequiredBeforeBuild(job, download) + }) + + it('has the dev-channel Windows build compile its own addons', () => { + const job = readWorkflow('dev-channel-win-build.yml').jobs['build-win'] + const build = stepIndex( + job, + (step) => step.run?.trim().split('\n').join('\n') === BUILD_BOTH_ARCHES.join('\n'), + 'addon build' + ) + expectRequiredBeforeBuild(job, build) + }) +}) diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 489623262b4..938e9d86d72 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -62,6 +62,9 @@ const EXPECTED_MATRIX = { [`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' }, [`${RELEASE_WORKFLOW}#publish-release`]: { contents: 'write' }, [`${RELEASE_WORKFLOW}#release-preflight`]: { contents: 'read' }, + [`${RELEASE_WORKFLOW}#relay-windows-process-tree`]: { contents: 'read' }, + [`${RELEASE_WORKFLOW}#relay-windows-process-tree -> .github/workflows/relay-windows-process-tree.yml#build`]: + { contents: 'read' }, [`${RELEASE_WORKFLOW}#skill-sharing-linux-floor-release-gate`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#skill-sharing-release-gate`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#terminal-rendering-golden`]: { contents: 'read' }, diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs index befbcf96acc..ba3c699a7a6 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -98,6 +98,49 @@ export function inspectWindowsProcessTreeAddon(addonPath) { return readFileSync(addonPath).includes(FLAGGED_IMPORT) ? 'unpatched' : 'clean' } +/** Only an addon built with the relay launcher patch registers this export. */ +const RELAY_LAUNCHER_EXPORT = 'spawnOutsideJob' + +/** + * Does this compiled addon carry the relay launcher? + * + * A byte search like the import check: the export name is a string literal in + * the image. A pre-launcher build is otherwise clean and loadable, so a stale + * `.build` dir or cached artifact would ship and the relay would fall back to + * WMI, which refuses a standard user. + */ +export function windowsProcessTreeAddonHasRelayLauncher(addonPath) { + return readFileSync(addonPath).includes(RELAY_LAUNCHER_EXPORT) +} + +/** + * Why this binary cannot ship as the `arch` relay addon, or null when it can. + * + * @param {string} addonPath + * @param {'x64' | 'arm64'} arch + * @returns {string | null} + */ +export function relayWindowsProcessTreeAddonDefect(addonPath, arch) { + const state = inspectWindowsProcessTreeAddon(addonPath) + if (state === 'missing') { + return 'it is missing' + } + if (state === 'unpatched') { + return `it imports ${FLAGGED_IMPORT}, so it was built from the unpatched command-line reader` + } + const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)( + './windows-pe-machine.cjs' + ) + const machine = readPeMachine(addonPath) + if (machine !== PE_MACHINE[arch]) { + return `it is ${describePeMachine(machine)}, not ${arch} (0x${PE_MACHINE[arch].toString(16)})` + } + if (!windowsProcessTreeAddonHasRelayLauncher(addonPath)) { + return `it does not export ${RELAY_LAUNCHER_EXPORT}, so it predates the relay launcher patch` + } + return null +} + export function assertWindowsProcessTreeCreationTimePatch( packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR ) { diff --git a/docs/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md index 46fd1f1bc14..d1845ec5cbe 100644 --- a/docs/reference/windows-edr-posture.md +++ b/docs/reference/windows-edr-posture.md @@ -370,10 +370,32 @@ Runtime-store GC (`src/main/ssh/remote-node-runtime-store-windows.ts`) reads the store in one PowerShell invocation. It learns which runtimes are in use from a single `Get-CimInstance Win32_Process` query, filtered on an image path under `runtimes\`. It never matches on the image name, so another program's node.exe -holds nothing. If the query fails, no process check has run and the pass keeps -everything. Windows itself also refuses to delete a running image, which is a +holds nothing. WMI refuses a standard user's SSH logon, so a refusal falls back to +`Get-Process`, which reads the image path of the account's own processes — the +only ones running from its store. If both fail, no process check has run and the +pass keeps everything. Windows itself also refuses to delete a running image, which is a second safeguard. +### SSH hosts: starting the relay outside the session + +Win32-OpenSSH puts each session's shell in a job with +`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK` +(`contrib/win32/win32compat/w32-doexec.c`, unchanged since 2018), so the relay +must leave that job to outlive the connection. It used to leave through WMI +`Win32_Process.Create`, which is both an EDR-scored remote-execution shape +(T1047) and refused to a standard user's network logon unless an administrator +grants Remote Enable on `root\cimv2`. + +Now `relay.js --windows-breakaway-launch` runs once per launch on the same +node.exe and calls `spawnOutsideJob` in the staged process-tree addon +(`src/process_launch.cc` in the patch): one `CreateProcessW` with +`CREATE_BREAKAWAY_FROM_JOB`, and a handle list that passes only the relay's +three stdio handles, so no SSH channel pipe is inherited. libuv never passes that +flag, so Node alone cannot do this. WMI remains only as the fallback for a relay +built without the addon or a job that refuses breakaway, and a refusal there is +reported as `ORCA_RELAY_LAUNCH_REFUSED`. The Windows SSH-host lanes run with no +WMI grant and assert the breakaway route. + ## Signing is not the gate The most useful calibration in the whole incident set came from the reporter's diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index e56bec2cd7a..affa11f7e4e 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -339,8 +339,22 @@ straight to the addon drops the duplicate. The artifact is optional in `RELAY_ARTIFACTS`: hashed when present, so a relay carrying it never shares an immutable directory with one that does not, and never probed, because requiring a file only a Windows build machine can produce -would make a correct relay read as MISSING and redeploy forever. A relay built -on any other OS keeps using the scan. +would make a correct relay read as MISSING and redeploy forever. A local build +on another OS has no addon, so its Windows relays use the scan. + +Every desktop package ships relays for Windows hosts, not just the Windows +installer, and the addon also carries the relay launcher (`spawnOutsideJob`, +see `windows-edr-posture.md`). So one Windows job, +`.github/workflows/relay-windows-process-tree.yml`, compiles both arches and +uploads the `relay-windows-process-tree` artifact. The release and dev-channel +macOS and Linux packaging jobs download it into `.build/windows-process-tree` +and set `ORCA_REQUIRE_RELAY_NATIVE_ADDONS=x64,arm64`, as the Windows jobs do. +`config/scripts/relay-windows-process-tree-staging.mjs` checks each staged +binary for its PE machine, the missing `ReadProcessMemory` import, and the +`spawnOutsideJob` export. Without that last check a pre-launcher build from an +old `.build` dir or cached artifact would pass. A required arch that fails any +check fails the build. An unrequired one (a local build) is left out, and that +relay uses the scan and the WMI launch fallback. ## Why the package is patched diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17332ca0bb1..b0f724a3ecc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,14 +109,14 @@ overrides: monaco-editor>dompurify: 3.4.15 patchedDependencies: - i18next-cli@1.74.2: 7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207 - '@vscode/windows-process-tree@0.8.0': b4be93859cefb159949d9cf05f97fa91a221dff0f793a6f03d02d78ae5b32035 + '@vscode/windows-process-tree@0.8.0': 9da74aa3d17243aa53dcdc95c9f06e97437e7fbccf098aeb017579e2d24cbac2 '@xterm/addon-image@0.10.0-beta.300': e5254a46d6f57bef4a8a19683bfa685afa0ca0127545aea53b54a48104ca3562 '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 '@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0 '@xterm/addon-serialize@0.15.0-beta.300': b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f '@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e '@xterm/xterm@6.1.0-beta.303': dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393 + i18next-cli@1.74.2: 7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207 lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 node-pty@1.1.0: 92c95cffab383d86b3b13a460c75a08074468ebf1f3911db8e874e083201f192 @@ -542,7 +542,7 @@ importers: version: link:native/windows-registry '@vscode/windows-process-tree': specifier: 0.8.0 - version: 0.8.0(patch_hash=b4be93859cefb159949d9cf05f97fa91a221dff0f793a6f03d02d78ae5b32035) + version: 0.8.0(patch_hash=9da74aa3d17243aa53dcdc95c9f06e97437e7fbccf098aeb017579e2d24cbac2) sherpa-onnx-darwin-arm64: specifier: 1.12.37 version: 1.12.37 @@ -10733,7 +10733,7 @@ snapshots: '@vscode/ripgrep-universal@1.18.0': {} - '@vscode/windows-process-tree@0.8.0(patch_hash=b4be93859cefb159949d9cf05f97fa91a221dff0f793a6f03d02d78ae5b32035)': + '@vscode/windows-process-tree@0.8.0(patch_hash=9da74aa3d17243aa53dcdc95c9f06e97437e7fbccf098aeb017579e2d24cbac2)': dependencies: node-addon-api: 7.1.0 optional: true diff --git a/src/main/ssh/remote-node-runtime-store-windows.test.ts b/src/main/ssh/remote-node-runtime-store-windows.test.ts index 91d584a4aee..ba43516f9c1 100644 --- a/src/main/ssh/remote-node-runtime-store-windows.test.ts +++ b/src/main/ssh/remote-node-runtime-store-windows.test.ts @@ -60,13 +60,24 @@ describe('Windows runtime store commands', () => { expect(script).toContain( `Get-CimInstance -ClassName Win32_Process -Filter "ExecutablePath LIKE '%\\\\runtimes\\\\%'" -Property ExecutablePath -ErrorAction Stop` ) - expect(script).not.toMatch(/Name\s*=|node\.exe|Get-Process/) + expect(script).not.toMatch(/Name\s*=|node\.exe|ProcessName/) // A failed query must not report that the check ran. expect(script.indexOf("Write-Output 'PROCESS_CHECK cim'")).toBeGreaterThan( script.indexOf('Get-CimInstance') ) }) + it('falls back to Get-Process image paths when WMI refuses a standard user', () => { + const script = decodeRemotePowerShellScript(windowsRuntimeStoreInventoryCommand(root)) + const fallback = script.slice(script.indexOf("Write-Output 'PROCESS_CHECK cim'")) + expect(fallback).toContain( + "Get-Process -ErrorAction Stop | Where-Object { $_.Path -and $_.Path.IndexOf('\\runtimes\\', [StringComparison]::OrdinalIgnoreCase) -ge 0 }" + ) + expect(fallback.indexOf("Write-Output 'PROCESS_CHECK process'")).toBeGreaterThan( + fallback.indexOf('Get-Process') + ) + }) + it('reads both ref shapes from every sibling of runtimes\\', () => { const script = decodeRemotePowerShellScript(windowsRuntimeStoreInventoryCommand(root)) expect(script).toContain("Join-Path $d.FullName '.runtime-node'") diff --git a/src/main/ssh/remote-node-runtime-store-windows.ts b/src/main/ssh/remote-node-runtime-store-windows.ts index fcca7083b97..c3335f71cb0 100644 --- a/src/main/ssh/remote-node-runtime-store-windows.ts +++ b/src/main/ssh/remote-node-runtime-store-windows.ts @@ -3,7 +3,10 @@ * `sh` passes, each as ONE PowerShell invocation (docs/reference/windows-edr-posture.md). * * Process holds come from one `Get-CimInstance Win32_Process` query filtered on the image path - * under `runtimes\`, never on the image name: another program's node.exe must hold nothing. + * under `runtimes\`, never on the image name: another program's node.exe must hold nothing. WMI + * refuses a standard user's SSH logon, so a refusal falls back to `Get-Process`, which reads the + * image path of this account's own processes -- the only ones that run from its store. Windows + * also refuses to delete a running image, which backs both. */ import { ORCAD_NODE_RUNTIME_DIR_PREFIX, @@ -64,7 +67,14 @@ export function windowsRuntimeStoreInventoryCommand(root: string): string { `$held = @(Get-CimInstance -ClassName Win32_Process -Filter "ExecutablePath LIKE '%\\\\${ORCAD_RUNTIMES_DIRNAME}\\\\%'" -Property ExecutablePath -ErrorAction Stop)`, "Write-Output 'PROCESS_CHECK cim'", "foreach ($p in $held) { if ($p.ExecutablePath) { Write-Output ('HOLD ' + $p.ExecutablePath) } }", + '} catch {', + // WMI refuses a standard user's SSH logon; this account's own relays still report their image. + 'try {', + `$held = @(Get-Process -ErrorAction Stop | Where-Object { $_.Path -and $_.Path.IndexOf(${powerShellLiteral(`\\${ORCAD_RUNTIMES_DIRNAME}\\`)}, [StringComparison]::OrdinalIgnoreCase) -ge 0 })`, + "Write-Output 'PROCESS_CHECK process'", + "foreach ($p in $held) { Write-Output ('HOLD ' + $p.Path) }", '} catch { }', + '}', `Write-Output ${powerShellLiteral(INVENTORY_OK)}` ].join('\n') ) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 74e830a1715..4e803d30763 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -118,6 +118,12 @@ import { } from './ssh-remote-platform' import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' +import { + classifyWindowsRelayLaunchError, + WINDOWS_RELAY_LAUNCH_LOG_PREFIX, + windowsRelayLaunchCommand +} from './ssh-relay-windows-launch-command' +import { parseRelayWindowsLaunchReport } from '../../shared/relay-windows-breakaway-launch' import { relaySocketNameForInstanceId } from './ssh-relay-instance-id' import { resolveRelayEndpointBeforeRelaunch } from './ssh-relay-endpoint-takeover' import { @@ -2399,23 +2405,26 @@ async function launchWindowsRelay( const logFile = joinRemotePath(hostPlatform, launchOpts.remoteDir, 'relay.log') const errFile = joinRemotePath(hostPlatform, launchOpts.remoteDir, 'relay.err.log') // Why no credential write: see launchRelay — the daemon publishes after it owns the pipe. - await execHostCommand( + const launchOutput = await execHostCommand( conn, hostPlatform, - windowsRelayLaunchCommand( - hostPlatform, - launchOpts.nodePath, - launchOpts.remoteDir, - launchOpts.sockPath, - launchOpts.endpointDir, - launchOpts.graceTime, + windowsRelayLaunchCommand(hostPlatform, { + nodePath: launchOpts.nodePath, + remoteDir: launchOpts.remoteDir, + sockPath: launchOpts.sockPath, + endpointDir: launchOpts.endpointDir, + graceTime: launchOpts.graceTime, logFile, errFile, - launchOpts.credentialFile, - launchOpts.ripgrepPath - ), + credentialFile: launchOpts.credentialFile, + ripgrepPath: launchOpts.ripgrepPath + }), { signal } - ) + ).catch((error: unknown) => { + throw classifyWindowsRelayLaunchError(error) + }) + const launchReport = parseRelayWindowsLaunchReport(launchOutput) + console.log(`${WINDOWS_RELAY_LAUNCH_LOG_PREFIX}${JSON.stringify(launchReport)}`) const POLL_INTERVAL_MS = 200 const POLL_TIMEOUT_MS = 10_000 @@ -2495,52 +2504,6 @@ function windowsRelayConnectCommand( ) } -function windowsRelayLaunchCommand( - hostPlatform: RemoteHostPlatform, - nodePath: string, - remoteDir: string, - sockPath: string, - endpointDir: string, - graceTime: number, - logFile: string, - errFile: string, - credentialFile: string, - ripgrepPath?: string -): string { - const relayScript = joinRemotePath(hostPlatform, remoteDir, 'relay.js') - // Why: Windows sshd kills the exec channel's process tree on close; WMI re-parents the detached relay to survive. - const quoted = (value: string): string => `"${value.replace(/"/g, '\\"')}"` - const relayCommandLine = [ - quoted(nodePath), - quoted(relayScript), - '--detached', - '--grace-time', - String(graceTime), - '--sock-path', - quoted(sockPath), - '--credential-file', - quoted(credentialFile), - '--endpoint-dir', - quoted(endpointDir), - // Why: --log-file owns rotation; shell redirects still capture pre-JS boot/crash output. - '--log-file', - quoted(logFile), - ...(ripgrepPath ? ['--ripgrep-path', quoted(ripgrepPath)] : []), - `1>${quoted(logFile)}`, - `2>${quoted(errFile)}` - ].join(' ') - const wmiCommandLine = `cmd.exe /d /s /c "${relayCommandLine}"` - return commandWithNodePath( - hostPlatform, - nodePath, - remoteDir, - [ - `$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(wmiCommandLine)}; CurrentDirectory = ${powerShellLiteral(remoteDir)} }`, - `if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }` - ].join('; ') - ) -} - async function probeWindowsRelayPipe( conn: SshConnection, hostPlatform: RemoteHostPlatform, diff --git a/src/main/ssh/ssh-relay-windows-host-lane.test.ts b/src/main/ssh/ssh-relay-windows-host-lane.test.ts index dbfc58991c0..6d0a978593b 100644 --- a/src/main/ssh/ssh-relay-windows-host-lane.test.ts +++ b/src/main/ssh/ssh-relay-windows-host-lane.test.ts @@ -12,6 +12,7 @@ vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) import { ORCAD_RUNTIMES_DIRNAME } from '../../shared/orcad-artifacts' import { RELAY_REMOTE_DIR } from './relay-protocol' +import { WINDOWS_RELAY_LAUNCH_LOG_PREFIX } from './ssh-relay-windows-launch-command' import type { SshConnection } from './ssh-connection' import { localHostObserver } from './ssh-hostile-host-observer' import { @@ -64,6 +65,16 @@ describe.runIf(RUN)('SSH relay on a Windows OpenSSH host', () => { expect(violations, `${cell.id}: ${violations.join('; ')}`).toEqual([]) } const receipt: Record = { cell: cell.id, target: descriptor.target, audits } + // The deploy logs each relay launch; the cell reads them to prove which route ran. + const launches: unknown[] = [] + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + const line = args.map(String).join(' ') + if (line.startsWith(WINDOWS_RELAY_LAUNCH_LOG_PREFIX)) { + launches.push(JSON.parse(line.slice(WINDOWS_RELAY_LAUNCH_LOG_PREFIX.length))) + } + process.stdout.write(`${line}\n`) + }) + receipt.launches = launches let conn: SshConnection | null = null try { conn = await connectHostileHost(sshTarget) @@ -87,6 +98,9 @@ describe.runIf(RUN)('SSH relay on a Windows OpenSSH host', () => { inspectDeploy }) Object.assign(receipt, evidence, { reused: true, gcKeptInUse: true }) + // One launch, outside sshd's job with no WMI grant; the second connect launched nothing, + // so it adopted the relay that outlived the first SSH connection. + expect(launches).toEqual([{ method: 'breakaway', pid: expect.any(Number), inJob: false }]) } else { // Opted out: nothing may enter the pinned runtime store, whatever the host-Node path did. const store = `${descriptor.home}/${RELAY_REMOTE_DIR}/${ORCAD_RUNTIMES_DIRNAME}` @@ -95,6 +109,7 @@ describe.runIf(RUN)('SSH relay on a Windows OpenSSH host', () => { } receipt.passed = true } finally { + logSpy.mockRestore() await conn?.disconnect().catch(() => {}) writeFileSync(descriptor.receipt, `${JSON.stringify(receipt, null, 2)}\n`) } diff --git a/src/main/ssh/ssh-relay-windows-launch-command.test.ts b/src/main/ssh/ssh-relay-windows-launch-command.test.ts new file mode 100644 index 00000000000..504ce0244ee --- /dev/null +++ b/src/main/ssh/ssh-relay-windows-launch-command.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + formatRelayWindowsLaunchReport, + parseRelayWindowsLaunchReport, + RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG +} from '../../shared/relay-windows-breakaway-launch' +import { + classifyWindowsRelayLaunchError, + WINDOWS_RELAY_LAUNCH_REFUSED_MARKER, + windowsRelayLaunchCommand +} from './ssh-relay-windows-launch-command' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { decodeRemotePowerShellScript } from './ssh-remote-powershell' + +const host = getRemoteHostPlatform('win32-x64') +const opts = { + nodePath: 'C:/Users/me user/.orca-remote/runtimes/node-abc/node.exe', + remoteDir: 'C:/Users/me user/.orca-remote/relay-1', + sockPath: '\\\\.\\pipe\\orca-relay-1', + endpointDir: 'C:/Users/me user/.orca-remote/relay-1/agent-hooks/orca-relay-1', + graceTime: 300, + logFile: 'C:/Users/me user/.orca-remote/relay-1/relay.log', + errFile: 'C:/Users/me user/.orca-remote/relay-1/relay.err.log', + credentialFile: 'C:/Users/me user/.orca-remote/relay-1/orca-relay-1.credential' +} + +function launchScript(): string { + return decodeRemotePowerShellScript(windowsRelayLaunchCommand(host, opts)) +} + +describe('windowsRelayLaunchCommand', () => { + it('starts the relay through the breakaway launcher on the same node.exe', () => { + const script = launchScript() + const launcher = `& '${opts.nodePath}' relay.js '${RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG}' '--stdout-file' '${opts.logFile}' '--stderr-file' '${opts.errFile}' '--relay-args' '--detached' '--grace-time' '300' '--sock-path' '${opts.sockPath}'` + expect(script).toContain(launcher) + expect(script.indexOf(launcher)).toBeLessThan(script.indexOf('Invoke-CimMethod')) + }) + + it('reaches WMI only when the launcher reports itself unavailable', () => { + const script = launchScript() + const wmiBranch = script.slice(script.indexOf('if ($orcaLaunchCode -eq 3)')) + expect(wmiBranch.indexOf('Invoke-CimMethod')).toBeGreaterThan(0) + expect(wmiBranch.indexOf('Invoke-CimMethod')).toBeLessThan(wmiBranch.indexOf('elseif')) + expect(script).toContain( + `"C:/Users/me user/.orca-remote/relay-1/relay.js" --detached --grace-time 300` + ) + expect(script).toContain(`1>"${opts.logFile}" 2>"${opts.errFile}"`) + }) + + it('names a WMI refusal so a standard-user host fails with a reason', () => { + const script = launchScript() + expect(script).toContain(`throw "${WINDOWS_RELAY_LAUNCH_REFUSED_MARKER}:`) + expect(script).toContain('Invoke-CimMethod -ErrorAction Stop') + }) + + it('emits nothing an EDR scores as a launch technique', () => { + const script = launchScript() + expect(script).not.toMatch(/Add-Type|ExecutionPolicy|Register-ScheduledTask|schtasks/iu) + }) + + it('passes the uploaded ripgrep to both routes', () => { + const script = decodeRemotePowerShellScript( + windowsRelayLaunchCommand(host, { ...opts, ripgrepPath: 'C:/rg/rg.exe' }) + ) + expect(script).toContain(`'--ripgrep-path' 'C:/rg/rg.exe'`) + expect(script).toContain('--ripgrep-path "C:/rg/rg.exe"') + }) +}) + +describe('classifyWindowsRelayLaunchError', () => { + it('turns a refusal into a named error', () => { + const exec = new Error( + `Command "powershell.exe -EncodedCommand AAAA" failed (exit 1): ${WINDOWS_RELAY_LAUNCH_REFUSED_MARKER}: this account cannot start a process that outlives the SSH session: breakaway launcher unavailable (ORCA_RELAY_LAUNCH {"method":"unavailable","reason":"addon-missing"}) and WMI Win32_Process.Create denied (Access denied)\nAt line:1 char:1` + ) + const classified = classifyWindowsRelayLaunchError(exec) + if (!(classified instanceof Error)) { + throw new Error('expected an Error') + } + expect(classified.message).toMatch( + /^The Windows host refused to start Orca's relay outside the SSH session\. ORCA_RELAY_LAUNCH_REFUSED: .*addon-missing.*Access denied\)$/u + ) + expect(classified.message).not.toContain('EncodedCommand') + }) + + it('leaves every other launch failure as it was', () => { + const exec = new Error('Command "x" failed (exit 1): Relay launcher exited 1') + expect(classifyWindowsRelayLaunchError(exec)).toBe(exec) + }) +}) + +describe('parseRelayWindowsLaunchReport', () => { + it('reads the launcher and WMI reports', () => { + const breakaway = formatRelayWindowsLaunchReport({ method: 'breakaway', pid: 7, inJob: false }) + expect(parseRelayWindowsLaunchReport(`noise\r\n${breakaway}\r\n`)).toEqual({ + method: 'breakaway', + pid: 7, + inJob: false + }) + const unavailable = formatRelayWindowsLaunchReport({ + method: 'unavailable', + reason: 'addon-missing' + }) + expect( + parseRelayWindowsLaunchReport(`${unavailable}\nORCA_RELAY_LAUNCH {"method":"wmi"}`) + ).toEqual({ method: 'wmi' }) + }) + + it('reads nothing from output without a report', () => { + expect(parseRelayWindowsLaunchReport('')).toBeNull() + expect(parseRelayWindowsLaunchReport('ORCA_RELAY_LAUNCH not-json')).toBeNull() + expect(parseRelayWindowsLaunchReport('ORCA_RELAY_LAUNCH {"method":"breakaway"}')).toBeNull() + }) +}) diff --git a/src/main/ssh/ssh-relay-windows-launch-command.ts b/src/main/ssh/ssh-relay-windows-launch-command.ts new file mode 100644 index 00000000000..9c110c69ad8 --- /dev/null +++ b/src/main/ssh/ssh-relay-windows-launch-command.ts @@ -0,0 +1,127 @@ +/** + * The PowerShell that starts a detached relay on a Windows SSH host. + * + * Win32-OpenSSH puts each session's shell in a job with KILL_ON_JOB_CLOSE, so a relay started + * inside the session dies with it. The job allows breakaway, so `relay.js`'s one-shot launcher + * mode starts the relay with CREATE_BREAKAWAY_FROM_JOB through the staged process-tree addon. That + * works for a standard user; WMI Win32_Process.Create, the old route, is refused to a standard + * user's network logon, so it runs only when the launcher is unavailable (a relay built without + * the addon, or a job that refuses breakaway), and a refusal there is reported as one. + */ +import { + RELAY_WINDOWS_BREAKAWAY_ARGS_FLAG, + RELAY_WINDOWS_BREAKAWAY_EXIT_CODES, + RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG, + RELAY_WINDOWS_BREAKAWAY_STDERR_FLAG, + RELAY_WINDOWS_BREAKAWAY_STDOUT_FLAG, + RELAY_WINDOWS_LAUNCH_REPORT_MARKER +} from '../../shared/relay-windows-breakaway-launch' +import { commandWithNodePath } from './ssh-remote-commands' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' + +/** Followed by the launch report as JSON, or `null` when the script printed none. */ +export const WINDOWS_RELAY_LAUNCH_LOG_PREFIX = '[ssh-relay] Windows relay launch: ' + +/** In the launch error when neither route may start a process outside the session. */ +export const WINDOWS_RELAY_LAUNCH_REFUSED_MARKER = 'ORCA_RELAY_LAUNCH_REFUSED' + +export type WindowsRelayLaunchCommandOptions = { + nodePath: string + remoteDir: string + sockPath: string + endpointDir: string + graceTime: number + logFile: string + errFile: string + credentialFile: string + ripgrepPath?: string +} + +function relayDaemonArgs(opts: WindowsRelayLaunchCommandOptions): string[] { + return [ + '--detached', + '--grace-time', + String(opts.graceTime), + '--sock-path', + opts.sockPath, + '--credential-file', + opts.credentialFile, + '--endpoint-dir', + opts.endpointDir, + // Why: --log-file owns rotation; the stdout/stderr files still capture pre-JS boot/crash output. + '--log-file', + opts.logFile, + ...(opts.ripgrepPath ? ['--ripgrep-path', opts.ripgrepPath] : []) + ] +} + +function wmiCommandLine(opts: WindowsRelayLaunchCommandOptions, relayScript: string): string { + const quoted = (value: string): string => `"${value.replace(/"/g, '\\"')}"` + const relayCommandLine = [ + quoted(opts.nodePath), + quoted(relayScript), + ...relayDaemonArgs(opts).map((arg) => + arg.startsWith('--') || arg === String(opts.graceTime) ? arg : quoted(arg) + ), + `1>${quoted(opts.logFile)}`, + `2>${quoted(opts.errFile)}` + ].join(' ') + return `cmd.exe /d /s /c "${relayCommandLine}"` +} + +export function windowsRelayLaunchCommand( + hostPlatform: RemoteHostPlatform, + opts: WindowsRelayLaunchCommandOptions +): string { + const relayScript = joinRemotePath(hostPlatform, opts.remoteDir, 'relay.js') + const launcherArgs = [ + RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG, + RELAY_WINDOWS_BREAKAWAY_STDOUT_FLAG, + opts.logFile, + RELAY_WINDOWS_BREAKAWAY_STDERR_FLAG, + opts.errFile, + RELAY_WINDOWS_BREAKAWAY_ARGS_FLAG, + ...relayDaemonArgs(opts) + ] + const launcher = `& ${powerShellLiteral(opts.nodePath)} relay.js ${launcherArgs.map(powerShellNativeArg).join(' ')}` + const refused = `${WINDOWS_RELAY_LAUNCH_REFUSED_MARKER}: this account cannot start a process that outlives the SSH session` + const wmi = [ + 'try {', + `$orcaWmi = Invoke-CimMethod -ErrorAction Stop -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(wmiCommandLine(opts, relayScript))}; CurrentDirectory = ${powerShellLiteral(opts.remoteDir)} }`, + `} catch { throw "${refused}: breakaway launcher unavailable ($orcaLaunch) and WMI Win32_Process.Create denied ($($_.Exception.Message))" }`, + `if ($orcaWmi.ReturnValue -ne 0) { throw "${refused}: breakaway launcher unavailable ($orcaLaunch) and Win32_Process.Create returned $($orcaWmi.ReturnValue)" }`, + `'${RELAY_WINDOWS_LAUNCH_REPORT_MARKER} {"method":"wmi"}'` + ].join('; ') + return commandWithNodePath( + hostPlatform, + opts.nodePath, + opts.remoteDir, + [ + `$orcaLaunch = (${launcher}) -join ' '`, + '$orcaLaunchCode = $LASTEXITCODE', + '$orcaLaunch', + `if ($orcaLaunchCode -eq ${RELAY_WINDOWS_BREAKAWAY_EXIT_CODES.unavailable}) { ${wmi} } ` + + `elseif ($orcaLaunchCode -ne ${RELAY_WINDOWS_BREAKAWAY_EXIT_CODES.launched}) { throw "Relay launcher exited $($orcaLaunchCode): $orcaLaunch" }` + ].join('; ') + ) +} + +/** + * A launch refusal is a host policy, not a transient failure: name it rather than surface the + * encoded command the exec error carries. + */ +export function classifyWindowsRelayLaunchError(error: unknown): unknown { + const message = error instanceof Error ? error.message : String(error) + const refusal = message + .split(/\r?\n/u) + .find((line) => line.includes(WINDOWS_RELAY_LAUNCH_REFUSED_MARKER)) + if (!refusal) { + return error + } + const detail = refusal.slice(refusal.indexOf(WINDOWS_RELAY_LAUNCH_REFUSED_MARKER)).trim() + return new Error( + `The Windows host refused to start Orca's relay outside the SSH session. ${detail}`, + { cause: error } + ) +} diff --git a/src/relay/relay-windows-breakaway-launch.test.ts b/src/relay/relay-windows-breakaway-launch.test.ts new file mode 100644 index 00000000000..b73ea73e1ca --- /dev/null +++ b/src/relay/relay-windows-breakaway-launch.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest' +import { quoteWindowsArgument } from '../shared/child-process/windows-command-line' +import { + RELAY_WINDOWS_BREAKAWAY_EXIT_CODES, + RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG +} from '../shared/relay-windows-breakaway-launch' +import { + launchRelayOutsideJob, + loadSpawnOutsideJob, + parseRelayWindowsBreakawayLaunch, + type SpawnOutsideJob +} from './relay-windows-breakaway-launch' + +const argv = [ + 'C:\\rt\\node.exe', + 'C:\\Users\\me user\\.orca-remote\\relay-1\\relay.js', + RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG, + '--stdout-file', + 'C:/Users/me user/relay.log', + '--stderr-file', + 'C:/Users/me user/relay.err.log', + '--relay-args', + '--detached', + '--sock-path', + '\\\\.\\pipe\\orca-relay-1', + '--log-file', + 'C:/Users/me user/relay.log' +] +const runtime = { execPath: argv[0], relayScript: argv[1], cwd: 'C:\\Users\\me user' } + +function requestFrom(args: readonly string[]) { + const request = parseRelayWindowsBreakawayLaunch(args) + if (!request) { + throw new Error('expected a launch request') + } + return request +} + +describe('relay Windows breakaway launcher', () => { + it('is not requested by an ordinary relay launch', () => { + expect(parseRelayWindowsBreakawayLaunch(['node', 'relay.js', '--detached'])).toBeNull() + }) + + it('takes its own flags before -- and hands the rest to the relay', () => { + expect(parseRelayWindowsBreakawayLaunch(argv)).toEqual({ + stdoutPath: 'C:/Users/me user/relay.log', + stderrPath: 'C:/Users/me user/relay.err.log', + relayArgs: argv.slice(8) + }) + }) + + it('refuses a request without its output files', () => { + expect(() => + parseRelayWindowsBreakawayLaunch(['node', 'relay.js', RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG]) + ).toThrow('--stdout-file') + }) + + it('starts the same node and relay script with the relay args, quoted for CommandLineToArgvW', () => { + const spawn = vi.fn(() => ({ ok: true, pid: 4242, inJob: false })) + + const outcome = launchRelayOutsideJob(requestFrom(argv), spawn, runtime) + + expect(outcome).toEqual({ + report: { method: 'breakaway', pid: 4242, inJob: false }, + exitCode: RELAY_WINDOWS_BREAKAWAY_EXIT_CODES.launched + }) + expect(spawn).toHaveBeenCalledWith( + runtime.execPath, + [runtime.execPath, runtime.relayScript, ...argv.slice(8)].map(quoteWindowsArgument).join(' '), + runtime.cwd, + 'C:/Users/me user/relay.log', + 'C:/Users/me user/relay.err.log' + ) + }) + + it('reports a job that refuses breakaway as unavailable so the script can try WMI', () => { + const spawn: SpawnOutsideJob = () => ({ + ok: false, + reason: 'breakaway-denied', + step: 'create-process', + code: 5 + }) + + expect(launchRelayOutsideJob(requestFrom(argv), spawn, runtime)).toEqual({ + report: { + method: 'unavailable', + reason: 'breakaway-denied', + step: 'create-process', + code: 5 + }, + exitCode: RELAY_WINDOWS_BREAKAWAY_EXIT_CODES.unavailable + }) + }) + + it('reports any other CreateProcess failure as failed, without a WMI retry', () => { + const spawn: SpawnOutsideJob = () => ({ + ok: false, + reason: 'failed', + step: 'open-stdout', + code: 32 + }) + + expect(launchRelayOutsideJob(requestFrom(argv), spawn, runtime)).toEqual({ + report: { method: 'failed', reason: 'failed', step: 'open-stdout', code: 32 }, + exitCode: RELAY_WINDOWS_BREAKAWAY_EXIT_CODES.failed + }) + }) + + it('reports a missing launcher as unavailable', () => { + expect(launchRelayOutsideJob(requestFrom(argv), 'addon-missing', runtime)).toEqual({ + report: { method: 'unavailable', reason: 'addon-missing' }, + exitCode: RELAY_WINDOWS_BREAKAWAY_EXIT_CODES.unavailable + }) + }) + + it('names an absent addon and one that predates the launcher', () => { + expect( + loadSpawnOutsideJob(() => { + throw new Error('Cannot find module') + }, 'win32') + ).toBe('addon-missing') + expect(loadSpawnOutsideJob(() => ({ getProcessList: () => {} }), 'win32')).toBe( + 'addon-predates-launcher' + ) + }) + + it('reads an unrecognised addon result as a failure', () => { + const spawn = loadSpawnOutsideJob(() => ({ spawnOutsideJob: () => ({ ok: true }) }), 'win32') + if (typeof spawn !== 'function') { + throw new Error(`expected a launcher, got ${spawn}`) + } + expect(spawn('a', 'b', 'c', 'd', 'e')).toEqual({ + ok: false, + reason: 'unrecognized-result', + step: 'create-process', + code: 0 + }) + }) + + it('never binds the addon off Windows', () => { + expect(loadSpawnOutsideJob(() => ({ spawnOutsideJob: () => ({}) }), 'linux')).toBe( + 'not-windows' + ) + }) +}) diff --git a/src/relay/relay-windows-breakaway-launch.ts b/src/relay/relay-windows-breakaway-launch.ts new file mode 100644 index 00000000000..72b5400deff --- /dev/null +++ b/src/relay/relay-windows-breakaway-launch.ts @@ -0,0 +1,154 @@ +/** + * One-shot launcher mode of `relay.js` on Windows SSH hosts: start the detached relay outside the + * SSH session's job object, then exit. + * + * Win32-OpenSSH kills a session's job when the session ends. The job allows breakaway, but libuv + * never asks for it, so the staged process-tree addon does the CreateProcessW. WMI, the old + * route, is refused to a standard user's network logon, so it is only the launch script's + * fallback for hosts whose relay predates this addon. + */ +import { createRequire } from 'node:module' +import { quoteWindowsArgument } from '../shared/child-process/windows-command-line' +import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../shared/relay-artifacts' +import { + formatRelayWindowsLaunchReport, + RELAY_WINDOWS_BREAKAWAY_ARGS_FLAG, + RELAY_WINDOWS_BREAKAWAY_EXIT_CODES, + RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG, + RELAY_WINDOWS_BREAKAWAY_STDERR_FLAG, + RELAY_WINDOWS_BREAKAWAY_STDOUT_FLAG, + type RelayWindowsLaunchReport +} from '../shared/relay-windows-breakaway-launch' + +type SpawnOutsideJobResult = + | { ok: true; pid: number; inJob: boolean } + | { ok: false; reason: string; step: string; code: number } + +export type SpawnOutsideJob = ( + application: string, + commandLine: string, + cwd: string, + stdoutPath: string, + stderrPath: string +) => SpawnOutsideJobResult + +type LaunchRequest = { + stdoutPath: string + stderrPath: string + relayArgs: string[] +} + +export function parseRelayWindowsBreakawayLaunch(argv: readonly string[]): LaunchRequest | null { + const flag = argv.indexOf(RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG) + if (flag === -1) { + return null + } + const separator = argv.indexOf(RELAY_WINDOWS_BREAKAWAY_ARGS_FLAG, flag) + const own = argv.slice(flag + 1, separator === -1 ? argv.length : separator) + const valueOf = (name: string): string => { + const index = own.indexOf(name) + const value = index === -1 ? undefined : own[index + 1] + if (!value) { + throw new Error(`${RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG} needs ${name}`) + } + return value + } + return { + stdoutPath: valueOf(RELAY_WINDOWS_BREAKAWAY_STDOUT_FLAG), + stderrPath: valueOf(RELAY_WINDOWS_BREAKAWAY_STDERR_FLAG), + relayArgs: separator === -1 ? [] : argv.slice(separator + 1) + } +} + +/** The staged addon's launcher, or why there is none. */ +export function loadSpawnOutsideJob( + requireNative: (specifier: string) => unknown = createRequire(__filename), + platform: NodeJS.Platform = process.platform +): SpawnOutsideJob | string { + if (platform !== 'win32') { + return 'not-windows' + } + let addon: unknown + try { + addon = requireNative(`./${RELAY_WINDOWS_PROCESS_TREE_FILENAME}`) + } catch { + return 'addon-missing' + } + const spawn = + addon && typeof addon === 'object' && 'spawnOutsideJob' in addon + ? addon.spawnOutsideJob + : undefined + if (typeof spawn !== 'function') { + return 'addon-predates-launcher' + } + return (...args) => readSpawnOutsideJobResult(spawn(...args)) +} + +function readSpawnOutsideJobResult(value: unknown): SpawnOutsideJobResult { + const record = value && typeof value === 'object' ? Object.fromEntries(Object.entries(value)) : {} + if (record.ok === true && typeof record.pid === 'number') { + return { ok: true, pid: record.pid, inJob: record.inJob === true } + } + return { + ok: false, + reason: typeof record.reason === 'string' ? record.reason : 'unrecognized-result', + step: typeof record.step === 'string' ? record.step : 'create-process', + code: typeof record.code === 'number' ? record.code : 0 + } +} + +export function launchRelayOutsideJob( + request: LaunchRequest, + spawnOutsideJob: SpawnOutsideJob | string, + runtime: { execPath: string; relayScript: string; cwd: string } +): { report: RelayWindowsLaunchReport; exitCode: number } { + if (typeof spawnOutsideJob === 'string') { + return { + report: { method: 'unavailable', reason: spawnOutsideJob }, + exitCode: RELAY_WINDOWS_BREAKAWAY_EXIT_CODES.unavailable + } + } + const commandLine = [runtime.execPath, runtime.relayScript, ...request.relayArgs] + .map(quoteWindowsArgument) + .join(' ') + const result = spawnOutsideJob( + runtime.execPath, + commandLine, + runtime.cwd, + request.stdoutPath, + request.stderrPath + ) + if (result.ok) { + return { + report: { method: 'breakaway', pid: result.pid, inJob: result.inJob }, + exitCode: RELAY_WINDOWS_BREAKAWAY_EXIT_CODES.launched + } + } + const failure = { reason: result.reason, step: result.step, code: result.code } + // A job without BREAKAWAY_OK is a host property, not a launch failure: WMI may still work. + return result.reason === 'breakaway-denied' + ? { + report: { method: 'unavailable', ...failure }, + exitCode: RELAY_WINDOWS_BREAKAWAY_EXIT_CODES.unavailable + } + : { + report: { method: 'failed', ...failure }, + exitCode: RELAY_WINDOWS_BREAKAWAY_EXIT_CODES.failed + } +} + +/** Runs the launcher mode when argv asks for it; false means run the relay normally. */ +export function runRelayWindowsBreakawayLaunchIfRequested(argv: readonly string[]): boolean { + const request = parseRelayWindowsBreakawayLaunch(argv) + if (!request) { + return false + } + const { report, exitCode } = launchRelayOutsideJob(request, loadSpawnOutsideJob(), { + execPath: process.execPath, + relayScript: argv[1] ?? '', + cwd: process.cwd() + }) + // Why exit: nothing this one-shot mode loaded may keep the launching SSH exec open. + process.stdout.write(`${formatRelayWindowsLaunchReport(report)}\n`, () => process.exit(exitCode)) + return true +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 9f49664599b..e986a19b709 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -9,6 +9,7 @@ import { runRelayDaemon } from './relay-daemon' import { relayLogLine } from './relay-diagnostic-log' import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' import { runRelayRuntimeSelfTestCommand } from './relay-runtime-self-test' +import { runRelayWindowsBreakawayLaunchIfRequested } from './relay-windows-breakaway-launch' import { RELAY_RUNTIME_SELF_TEST_FLAG } from '../shared/relay-runtime-self-test-report' async function main(): Promise { @@ -17,6 +18,9 @@ async function main(): Promise { await runRelayRuntimeSelfTestCommand(process.argv[selfTestFlag + 1] ?? '') return } + if (runRelayWindowsBreakawayLaunchIfRequested(process.argv)) { + return + } const options = parseRelayLaunchOptions(process.argv) if (options.connectMode) { runRelayConnectChannel(options.sockPath, readRelayEndpointCredential(options.credentialFile)) diff --git a/src/shared/relay-windows-breakaway-launch.ts b/src/shared/relay-windows-breakaway-launch.ts new file mode 100644 index 00000000000..5c7407fa8bd --- /dev/null +++ b/src/shared/relay-windows-breakaway-launch.ts @@ -0,0 +1,71 @@ +/** + * The contract between the Windows relay launch script (main) and the one-shot launcher mode of + * `relay.js` that starts the detached relay outside the SSH session's job object. + */ + +export const RELAY_WINDOWS_BREAKAWAY_LAUNCH_FLAG = '--windows-breakaway-launch' +export const RELAY_WINDOWS_BREAKAWAY_STDOUT_FLAG = '--stdout-file' +export const RELAY_WINDOWS_BREAKAWAY_STDERR_FLAG = '--stderr-file' +/** Everything after it is the relay's own argv. Not `--`: Windows PowerShell may consume that. */ +export const RELAY_WINDOWS_BREAKAWAY_ARGS_FLAG = '--relay-args' + +/** The launcher's one report line, followed by a JSON object. */ +export const RELAY_WINDOWS_LAUNCH_REPORT_MARKER = 'ORCA_RELAY_LAUNCH' + +/** + * `unavailable` means this host cannot use the launcher (no addon, an addon that predates it, or + * a job that refuses breakaway); the launch script then tries WMI. + */ +export const RELAY_WINDOWS_BREAKAWAY_EXIT_CODES = { + launched: 0, + failed: 1, + unavailable: 3 +} as const + +export type RelayWindowsLaunchReport = + | { method: 'breakaway'; pid: number; inJob: boolean } + | { method: 'wmi' } + | { method: 'unavailable'; reason: string; step?: string; code?: number } + | { method: 'failed'; reason: string; step?: string; code?: number } + +/** The last report line in `output`, or null when none parses. */ +export function parseRelayWindowsLaunchReport(output: string): RelayWindowsLaunchReport | null { + const lines = output + .split(/\r?\n/u) + .filter((line) => line.startsWith(RELAY_WINDOWS_LAUNCH_REPORT_MARKER)) + const last = lines.at(-1) + if (!last) { + return null + } + try { + const parsed: unknown = JSON.parse(last.slice(RELAY_WINDOWS_LAUNCH_REPORT_MARKER.length).trim()) + if (!parsed || typeof parsed !== 'object' || !('method' in parsed)) { + return null + } + const record = Object.fromEntries(Object.entries(parsed)) + switch (record.method) { + case 'breakaway': + return typeof record.pid === 'number' + ? { method: 'breakaway', pid: record.pid, inJob: record.inJob === true } + : null + case 'wmi': + return { method: 'wmi' } + case 'unavailable': + case 'failed': + return { + method: record.method, + reason: typeof record.reason === 'string' ? record.reason : 'unknown', + ...(typeof record.step === 'string' ? { step: record.step } : {}), + ...(typeof record.code === 'number' ? { code: record.code } : {}) + } + default: + return null + } + } catch { + return null + } +} + +export function formatRelayWindowsLaunchReport(report: RelayWindowsLaunchReport): string { + return `${RELAY_WINDOWS_LAUNCH_REPORT_MARKER} ${JSON.stringify(report)}` +}