Files
orca/src/main/ssh/remote-node-runtime-store-windows.ts
T
OrcaWinandm4air 6d1a97ef98 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>
2026-10-01 05:32:09 -07:00

104 lines
6.2 KiB
TypeScript

/**
* The Windows half of runtime store GC: the same inventory lines and stage sweep as the POSIX
* `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. 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,
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
ORCAD_RUNTIMES_DIRNAME
} from '../../shared/orcad-artifacts'
import { RUNTIME_STORE_STAGE_PREFIX } from './orcad-remote-node-runtime'
import {
INVENTORY_OK,
MAX_DIRS,
REFS_ERR,
RUNTIME_REF_NODE_PREFIX,
RUNTIME_STORE_TOMBSTONE_PREFIX,
runtimeStoreInventoryCommand
} from './remote-node-runtime-store-inventory'
import { RELAY_REMOTE_DIR } from './relay-protocol'
import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
const REPARSE = '[IO.FileAttributes]::ReparsePoint'
/** The store inventory for either host dialect. */
export function hostRuntimeStoreInventoryCommand(
host: RemoteHostPlatform,
remoteHome: string
): string {
return isWindowsRemoteHost(host)
? windowsRuntimeStoreInventoryCommand(joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR))
: runtimeStoreInventoryCommand(host, remoteHome)
}
/** Same tags as `runtimeStoreInventoryCommand`; `root` is `~/.orca-remote`. */
export function windowsRuntimeStoreInventoryCommand(root: string): string {
const fail = `Write-Output ${powerShellLiteral(REFS_ERR)}; exit 0`
return powerShellCommand(
[
"$ProgressPreference = 'SilentlyContinue'",
`$root = ${powerShellLiteral(root)}`,
`$rt = Join-Path $root ${powerShellLiteral(ORCAD_RUNTIMES_DIRNAME)}`,
`if (-not (Test-Path -LiteralPath $rt -PathType Container)) { Write-Output ${powerShellLiteral(INVENTORY_OK)}; exit 0 }`,
// Why every sibling and not a prefix: a newer Orca's install may name a runtime too.
`try { $dirs = @(Get-ChildItem -LiteralPath $root -Force -Directory -ErrorAction Stop | Where-Object { $_.Name -ne ${powerShellLiteral(ORCAD_RUNTIMES_DIRNAME)} }) } catch { ${fail} }`,
`if ($dirs.Count -gt ${MAX_DIRS}) { ${fail} }`,
'foreach ($d in $dirs) {',
"Write-Output ('DIR ' + $d.Name)",
`$marker = Join-Path $d.FullName ${powerShellLiteral(ORCAD_NODE_RUNTIME_MARKER_FILENAME)}`,
`if (Test-Path -LiteralPath $marker) { try { Write-Output ('REF ' + [IO.File]::ReadAllText($marker).Trim()) } catch { ${fail} } }`,
// Why StartsWith and not -Filter: -Filter also matches 8.3 short names.
`try { $refs = @(Get-ChildItem -LiteralPath $d.FullName -Force -ErrorAction Stop | Where-Object { (-not $_.PSIsContainer) -and $_.Name.StartsWith(${powerShellLiteral(RUNTIME_REF_NODE_PREFIX)}) }) } catch { ${fail} }`,
`foreach ($f in $refs) { Write-Output ('REF ' + $f.Name.Substring(${RUNTIME_REF_NODE_PREFIX.length})) }`,
'}',
`try { $entries = @(Get-ChildItem -LiteralPath $rt -Force -Directory -ErrorAction Stop | Where-Object { $_.Name.StartsWith(${powerShellLiteral(ORCAD_NODE_RUNTIME_DIR_PREFIX)}) -or $_.Name.StartsWith(${powerShellLiteral(`${RUNTIME_STORE_TOMBSTONE_PREFIX}${ORCAD_NODE_RUNTIME_DIR_PREFIX}`)}) }) } catch { ${fail} }`,
"foreach ($e in $entries) { Write-Output ('ENTRY ' + $e.Name) }",
`$verified = @($entries | Where-Object { $_.Name.StartsWith(${powerShellLiteral(ORCAD_NODE_RUNTIME_DIR_PREFIX)}) } | ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName '.verified') -Force -ErrorAction SilentlyContinue } | Where-Object { $null -ne $_ })`,
"foreach ($v in @($verified | Sort-Object LastWriteTimeUtc -Descending)) { Write-Output ('VERIFIED ' + $v.Directory.Name) }",
// Process checks only add holds, so a failed query keeps everything (no PROCESS_CHECK).
'try {',
`$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')
)
}
/** Mirrors `sweepStaleRuntimeStagesCommand`: a stage nothing has written to within the stale rule. */
export function windowsSweepStaleRuntimeStagesCommand(
storeDir: string,
staleMinutes: number,
sweptTag: string
): string {
return powerShellCommand(
[
"$ProgressPreference = 'SilentlyContinue'",
`$cutoff = [DateTime]::UtcNow.AddMinutes(-${staleMinutes})`,
`$stages = @(Get-ChildItem -LiteralPath ${powerShellLiteral(storeDir)} -Force -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith(${powerShellLiteral(RUNTIME_STORE_STAGE_PREFIX)}) })`,
'foreach ($s in $stages) {',
`if ((($s.Attributes -band ${REPARSE}) -ne 0) -or ($s.LastWriteTimeUtc -ge $cutoff)) { continue }`,
// A listing that cannot answer keeps the stage.
'try { $recent = @(Get-ChildItem -LiteralPath $s.FullName -Force -Recurse -ErrorAction Stop | Where-Object { $_.LastWriteTimeUtc -ge $cutoff } | Select-Object -First 1) } catch { continue }',
'if ($recent.Count -gt 0) { continue }',
`try { Remove-Item -LiteralPath $s.FullName -Recurse -Force -ErrorAction Stop; Write-Output (${powerShellLiteral(`${sweptTag} `)} + $s.Name) } catch { }`,
'}'
].join('\n')
)
}