mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user