mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* feat(process): add the Windows-correct child-process chokepoint Six decisions have to be made every time Orca starts a child process -- console visibility, argument quoting, .cmd interpretation, binary resolution, timeout policy, and how the tree is later terminated. POSIX forgives all six. Windows punishes each differently, and made per-call site across 172 files they were right in some and wrong in others. runProcess/spawnProcess make them once: - windowsHide unconditionally, shell:false unconditionally (shell:true concatenates argv unescaped and silently disables windowsHide) - .cmd/.bat routed through cmd.exe /d /v:off /s /c with a verbatim line, because Node refuses to spawn them otherwise (EINVAL) The encoding was derived by measurement on Windows 11, not from the docs. An embedded quote is written "" rather than \" so cmd's naive quote count stays even -- with \" the parity flips and every later & | < > on the line stops being data. Measured before the fix, argv ["a b", 'c"d', "e%F%g", "h&i", "j^k"] arrived as ["a b", 'c"d', "e^%F^%g", "h"]: the & truncated the argument and ran its remainder as a command. Each % is broken out of the quoted run as "^%" because %VAR% expands even inside quotes. The import-boundary test is a ratchet seeded at today's 172 files; it only shrinks. * fix(process): route the console-flashing spawn sites through the chokepoint The ssh -G config probe fires on every connect and reconnect, and ssh.exe is console-subsystem, so a GUI-subsystem parent gets a fresh visible conhost that takes foreground -- keystrokes typed into an Orca terminal at that moment go into the black box (#10488, #14543). Same for the ProxyJump tunnel, the ProxyCommand cmd.exe wrapper, the font enumeration and the DPAPI cookie decrypt. Also stops spawning powershell by bare name: PATH under Electron is not the user's, so where policy has pruned the System32 entry the spawn fails and the font picker silently reports five hardcoded families rather than an error (#11771). Deletes system-fonts' 40-line bespoke execFileText -- timeout, output cap and kill are the chokepoint's job now. Adds runProcessSync so the sync callers have a compliant path; without one the ratchet could never reach zero. The three suites that mocked child_process directly now mock runProcess, which is the point: how a process gets started is no longer each module's business. Ratchet 173 -> 170. * fix(process): do not report a deliberately killed child as timed out runProcessSync inferred a timeout from signal === 'SIGTERM'. Measured: a real timeout sets error.code ETIMEDOUT and kills with SIGTERM, but so does anything else that terminates the child -- and those cases set no error at all. Reading the signal alone reports a process someone stopped on purpose as having timed out, which callers retry. * refactor(process): hold the ratchet as data and migrate the pwsh probes The allowlist and the adversarial argument corpus are read only by tests, so they were production modules in name only; they move to __fixtures__. pwsh.ts carried isTimeoutError() purely to reconcile two spellings of the same event -- execFileSync reports a timeout as ETIMEDOUT, the execFile callback as a SIGTERM kill with no code. runProcess reports one timedOut flag, so the helper and the reasoning behind it both go. Its sync probe also spawned without windowsHide, which flashes a console and steals foreground on every cold cache read. * refactor(process): migrate five more spawn sites onto the chokepoint Each one deletes a hand-rolled promise/timeout/kill wrapper and stops re-deciding console visibility for itself. Ratchet 170 -> 164. Two things this surfaced, both kept: runProcess now accepts string chunks as well as buffers. A stream someone called setEncoding on emits strings, and concatenating those as buffers throws inside a data handler -- where the rejection has nowhere to go and the caller simply hangs rather than failing. ProcessSpec keeps its AbortSignal. I had removed it as unused; the macOS PAM preflight passes one through from its own caller. ipc/app.ts is deliberately NOT migrated. Its probe spawns a three-stage pipeline detached so a timeout can reap the group with one negative-pid SIGKILL; runProcess kills only the root, which would orphan the plutil stages. Migrating it needs the chokepoint to own POSIX process-group termination first -- the same guarantee job objects give on Windows. Reverted and left on the ratchet. * test(process): do not assert a POSIX signal on Windows Windows has no signals, so the same deliberate kill reports an exit code there and a signal on POSIX. What has to hold on both is that neither shape reads as a timeout. Caught by running the suite on Windows. (cherry picked from commit 0a6e9902a22a369a0e85e113ea8d87b726f82e1f) * fix(process): settle a timed-out run even when the child ignores the kill close only fires once the child is actually gone, so a child that traps SIGTERM never emits it and the promise outlives its own deadline forever. That is the same wedge shape just fixed for the process table, and it is worse here: pwsh.ts and the snapshot reader both cache an in-flight probe, so one unkillable child hands every later caller the same dead promise. After the deadline it now escalates to SIGKILL and settles regardless, reporting timedOut with whatever output arrived. (cherry picked from commit 78ac169197c4e6faee1b9310a7186029cc11acbc) * fix(process): escalate an aborted child too, not just a timed-out one The grace escalation I added covered the timeout path and left abort on the old one, so an aborted caller with an unkillable child still waited forever -- the same defect, one path over. The macOS PAM preflight is a real caller that passes an AbortSignal. Both paths now share one stop-and-settle, and the result reports timedOut honestly: false when the caller aborted. (cherry picked from commit 7e9523a9e31172bb8183661b56f04c3ab6a03d0d) * fix(windows): stop percent escaping from forging an escaped quote escapePercentForCmd ran as a post-pass over the quoted string, so it inserted a quote wherever a percent was -- including straight after a backslash. CommandLineToArgvW reads backslash-quote as an escaped quote, so C:\Users\%USERNAME%\x arrived corrupted. That is about as common as Windows paths get, and my 20-case corpus had no backslash-before-percent entry to catch it. Percent handling is now part of the quoting loop, where the backslash run is known and can be doubled before the inserted quote. Two corpus cases cover the shape. The program path gets the same treatment. It was quoted but not percent-escaped, so a launcher under C:\Users\%USERNAME%\ had its own path expanded on the cmd hop. quoteWindowsArgument no longer takes a boolean. Passing it to values.map() handed map's index in as the flag -- which is how the first version of this fix was written, and the corpus test caught it. Separately: an AbortSignal that was already aborted never fires the event, so runProcess ran the child to its full timeout for a caller who had already given up. (cherry picked from commit f7e2e56b1ee1f27ab6d1035dde4501b38f95b374)
186 lines
6.6 KiB
TypeScript
186 lines
6.6 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process'
|
|
import { spawnProcess } from '../../shared/child-process/run-process'
|
|
import { Duplex } from 'node:stream'
|
|
import type { Socket as NetSocket } from 'node:net'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
import type { SshResolvedConfig } from './ssh-config-parser'
|
|
import { shellEscape } from './ssh-connection-utils'
|
|
import { isOpenSshConfigBackedTarget } from './system-ssh-args'
|
|
|
|
// Why: ProxyJump and jumpHost are syntactic sugar for ProxyCommand.
|
|
// OpenSSH internally converts `ProxyJump bastion` to
|
|
// `ProxyCommand ssh -W %h:%p bastion`. We do the same so that ssh2
|
|
// gets a single proxy spawn path regardless of how the tunnel was configured.
|
|
export type EffectiveProxy =
|
|
| { kind: 'proxy-command'; command: string }
|
|
| { kind: 'jump-host'; jumpHost: string }
|
|
|
|
export function resolveEffectiveProxy(
|
|
target: SshTarget,
|
|
resolved: SshResolvedConfig | null
|
|
): EffectiveProxy | undefined {
|
|
if (isOpenSshConfigBackedTarget(target) && resolved) {
|
|
if (resolved.proxyCommand) {
|
|
return { kind: 'proxy-command', command: resolved.proxyCommand }
|
|
}
|
|
return resolved.proxyJump ? { kind: 'jump-host', jumpHost: resolved.proxyJump } : undefined
|
|
}
|
|
if (target.proxyCommand) {
|
|
return { kind: 'proxy-command', command: target.proxyCommand }
|
|
}
|
|
if (resolved?.proxyCommand) {
|
|
return { kind: 'proxy-command', command: resolved.proxyCommand }
|
|
}
|
|
const jump = target.jumpHost || resolved?.proxyJump
|
|
if (jump) {
|
|
return { kind: 'jump-host', jumpHost: jump }
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// Why: cmd.exe has no quoting that survives arbitrary values — `%VAR%` expands
|
|
// inside quotes and `""` re-opens the unquoted state — so the only safe Windows
|
|
// expansion is to reject metacharacters instead of pretending to escape them.
|
|
// Hosts, ports and usernames never legitimately contain these.
|
|
const CMD_UNSAFE_PATTERN = /["%&|<>^\r\n]/
|
|
|
|
function cmdEscape(s: string): string {
|
|
if (CMD_UNSAFE_PATTERN.test(s)) {
|
|
throw new Error(
|
|
`ProxyCommand value cannot be safely expanded on Windows (unsupported characters): ${s}`
|
|
)
|
|
}
|
|
return `"${s}"`
|
|
}
|
|
|
|
type ShellSpawnConfig = { file: string; args: string[]; windowsVerbatimArguments: boolean }
|
|
|
|
// Why: ssh2 doesn't natively support ProxyCommand. When the SSH config
|
|
// specifies one (e.g. `cloudflared access ssh --hostname %h`), we spawn
|
|
// the command and bridge its stdin/stdout into a Duplex stream that ssh2
|
|
// uses as its transport socket via `config.sock`.
|
|
function getShellSpawnConfig(command: string): ShellSpawnConfig {
|
|
if (process.platform === 'win32') {
|
|
const comspec = process.env.ComSpec || 'cmd.exe'
|
|
// Why: mirror Node's own `shell: true` form. `/s` makes cmd.exe strip the
|
|
// outer quotes and take the rest verbatim; without verbatim arguments Node
|
|
// would backslash-escape inner quotes, which cmd.exe does not understand.
|
|
return {
|
|
file: comspec,
|
|
args: ['/d', '/s', '/c', `"${command}"`],
|
|
windowsVerbatimArguments: true
|
|
}
|
|
}
|
|
return { file: '/bin/sh', args: ['-c', command], windowsVerbatimArguments: false }
|
|
}
|
|
|
|
// Why: ProxyJump takes a comma-separated chain, but `ssh -W host:port dest`
|
|
// only tunnels through a single final hop. Preceding hops become that hop's own
|
|
// -J chain, which is how OpenSSH expands a multi-hop ProxyJump.
|
|
function jumpHostSpawnArgs(jumpHost: string, host: string, port: number): string[] {
|
|
const hops = jumpHost
|
|
.split(',')
|
|
.map((hop) => hop.trim())
|
|
.filter(Boolean)
|
|
const destination = hops.at(-1) ?? jumpHost
|
|
const chain = hops.slice(0, -1)
|
|
return [
|
|
'-W',
|
|
`${host}:${port}`,
|
|
...(chain.length > 0 ? ['-J', chain.join(',')] : []),
|
|
'--',
|
|
destination
|
|
]
|
|
}
|
|
|
|
export function spawnProxyCommand(
|
|
proxy: EffectiveProxy,
|
|
host: string,
|
|
port: number,
|
|
user: string
|
|
): { process: ChildProcess; sock: NetSocket } {
|
|
const proc =
|
|
proxy.kind === 'jump-host'
|
|
? // Why: ProxyJump is structured input, not a shell snippet. Spawn ssh
|
|
// directly so jump-host values cannot escape through shell parsing.
|
|
spawnProcess({
|
|
program: 'ssh',
|
|
args: jumpHostSpawnArgs(proxy.jumpHost, host, port)
|
|
})
|
|
: (() => {
|
|
const escape = process.platform === 'win32' ? cmdEscape : shellEscape
|
|
const expanded = proxy.command
|
|
.replace(/%h/g, escape(host))
|
|
.replace(/%p/g, escape(String(port)))
|
|
.replace(/%r/g, escape(user))
|
|
const shell = getShellSpawnConfig(expanded)
|
|
// Why not spawnProcess here: a ProxyCommand is a user-authored shell
|
|
// snippet, so it keeps its own verbatim command line. The console
|
|
// still has to be hidden -- a cmd.exe spawn from a GUI process always
|
|
// flashes and steals foreground otherwise (#10488).
|
|
return spawn(shell.file, shell.args, {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
windowsVerbatimArguments: shell.windowsVerbatimArguments
|
|
})
|
|
})()
|
|
|
|
// Why: a single PassThrough for both directions creates a feedback loop.
|
|
// Reads come from the proxy's stdout; writes go to its stdin.
|
|
let cleanedUp = false
|
|
const cleanup = (): void => {
|
|
if (cleanedUp) {
|
|
return
|
|
}
|
|
cleanedUp = true
|
|
proc.stdout!.off('data', onStdoutData)
|
|
proc.stdout!.off('end', onStdoutEnd)
|
|
proc.stderr!.off('data', onStderrData)
|
|
proc.stdin!.off('error', onInputError)
|
|
proc.off('error', onProcessError)
|
|
}
|
|
const onStdoutData = (data: Buffer): void => {
|
|
// Why: honour the Duplex's backpressure so a slow ssh2 consumer cannot
|
|
// buffer the proxy's output unboundedly.
|
|
if (!stream.push(data)) {
|
|
proc.stdout!.pause()
|
|
}
|
|
}
|
|
// Why: an undrained stderr pipe fills and blocks the proxy process, which
|
|
// looks like a silently hung connection.
|
|
const onStderrData = (data: Buffer): void => {
|
|
const text = data.toString('utf-8').trimEnd()
|
|
if (text) {
|
|
console.error(`[ssh-proxy-command] ${text}`)
|
|
}
|
|
}
|
|
const onStdoutEnd = (): void => {
|
|
stream.push(null)
|
|
}
|
|
const onInputError = (err: Error): void => {
|
|
stream.destroy(err)
|
|
}
|
|
const onProcessError = (err: Error): void => {
|
|
stream.destroy(err)
|
|
}
|
|
const stream = new Duplex({
|
|
read() {
|
|
proc.stdout!.resume()
|
|
},
|
|
write(chunk, _encoding, cb) {
|
|
proc.stdin!.write(chunk, cb)
|
|
},
|
|
destroy(err, cb) {
|
|
cleanup()
|
|
cb(err)
|
|
}
|
|
})
|
|
proc.stdout!.on('data', onStdoutData)
|
|
proc.stdout!.on('end', onStdoutEnd)
|
|
proc.stderr!.on('data', onStderrData)
|
|
proc.stdin!.on('error', onInputError)
|
|
proc.on('error', onProcessError)
|
|
|
|
return { process: proc, sock: stream as unknown as NetSocket }
|
|
}
|