fix(ssh): launch the Windows relay outside sshd's job so standard users work (#24224)

* fix(ssh): launch the Windows relay outside sshd's job without WMI

Win32-OpenSSH kills a session's job on close but allows breakaway. relay.js
gains a one-shot launcher mode that starts the detached relay with
CREATE_BREAKAWAY_FROM_JOB through the staged process-tree addon, so a standard
user no longer needs a WMI Remote Enable grant. WMI stays as the fallback for a
relay without the addon, and a refusal there is named. The Windows SSH-host
lanes drop their WMI grant and assert the breakaway route and adoption.

* fix(ssh): find runtime holds without WMI on a standard-user Windows host

The store GC read held runtimes through Get-CimInstance Win32_Process, which
WMI refuses to a standard user's SSH logon, so the pass kept every runtime.
On a refusal it now reads this account's own process image paths through
Get-Process.

* build(relay): ship the Windows relay launcher addon in every desktop package

macOS and Linux packages carried Windows relays without windows-process-tree.node,
so a legacy-runtime relay they uploaded to a Windows SSH host could not launch
outside sshd's job and fell back to WMI, which a standard user is refused.

A reusable Windows job now compiles the x64 and arm64 addons once and uploads
them; release-cut, release-mac-build, and the hourly/daily/adhoc mac builds
download them before build:release and require both arches. Staging now rejects
a binary with the wrong PE machine, the ReadProcessMemory import, or no
spawnOutsideJob export, so a stale pre-launcher build cannot ship.

* ci(ssh): run the Windows SSH-host lanes when the relay process-tree build scripts change

The staging and gyp-rebuild scripts decide which windows-process-tree addon the
relay ships, so a change to either must re-prove the Windows host cells.

* test(ci): find the mac orcad-template download by artifact name

The release mac job now also downloads the relay Windows process-tree addons, so
the first download-artifact step is no longer the template's.

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-10-01 05:32:09 -07:00
committed by GitHub
co-authored by m4air
parent 8afa1db50c
commit 6d1a97ef98
31 changed files with 1479 additions and 164 deletions
+24
View File
@@ -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
+25
View File
@@ -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
+25 -1
View File
@@ -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
@@ -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
+27 -16
View File
@@ -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
+17
View File
@@ -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
+10
View File
@@ -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
@@ -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=<ReturnValue> 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=<ReturnValue>
# or ORCA_WMI=denied.
function Test-PrivateWmiLaunch([string]$Account) {
$out=Invoke-PrivateSsh $Account 'powershell.exe -NoProfile -NonInteractive -Command "try{$r=Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine=''cmd.exe /d /c exit 0''} -ErrorAction Stop;''ORCA_WMI=''+$r.ReturnValue}catch{''ORCA_WMI=denied''}"'
$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
@@ -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 <napi.h>
#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<ProcessInfo>& 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<ProcessInfo>& 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 <windows.h>
+#include <string>
+#include <vector>
+
+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<Napi::String>().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<double>(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<wchar_t> 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<unsigned char> attribute_storage(attribute_size);
+ auto* attribute_list =
+ reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(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<double>(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.h>
+
+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.
*/
+18 -32
View File
@@ -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
@@ -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)
@@ -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
@@ -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',
@@ -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'
}
@@ -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'])
})
})
@@ -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)
})
})
@@ -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' },
@@ -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
) {
+24 -2
View File
@@ -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
+16 -2
View File
@@ -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
+4 -4
View File
@@ -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
@@ -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'")
@@ -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')
)
+21 -58
View File
@@ -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,
@@ -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<string, unknown> = { 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`)
}
@@ -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()
})
})
@@ -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 }
)
}
@@ -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<SpawnOutsideJob>(() => ({ 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'
)
})
})
+154
View File
@@ -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
}
+4
View File
@@ -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<void> {
@@ -17,6 +18,9 @@ async function main(): Promise<void> {
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))
@@ -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)}`
}