mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(computer-use): run the Windows runtime as one persistent helper (#17858)
* fix(computer-use): run the Windows runtime as one persistent helper
Microsoft Defender for Endpoint raised multi-stage Execution + Collection
incidents against Orca on Windows ("Screenshots were taken unexpectedly on
this device... Screen capture code was found in a script launched by
powershell.exe", factor "Executes suspicious MSIL code"). The desktop script
provider spawned a fresh powershell.exe per operation, so a single computer-use
session produced a burst of short-lived PIDs and re-emitted runtime.ps1's
inline Add-Type P/Invoke assembly on every click.
runtime.ps1 gains a -Serve mode that loads its assemblies once and then reads
NDJSON requests from stdin, and a new DesktopScriptRuntimeHost owns one
long-lived child: lazy spawn, strict serialization, a 30s per-request timeout,
restart on crash, a 120s idle shutdown, and dispose() on provider teardown. The
one-shot -OperationPath path stays as the fallback, and Linux keeps its python3
bridge unchanged.
Both Windows spawn sites now use -ExecutionPolicy RemoteSigned instead of
Bypass, falling back once to Bypass (and logging) when a Restricted host
refuses the unsigned script.
* fix(computer-use): recover the runtime host instead of latching it off
Review follow-up on the persistent Windows computer-use helper.
A helper that died before producing a line set an unavailable flag nothing ever
cleared, and the client then dropped the host for the life of the session. One
transient bad spawn — a Defender scan, a locked CSC temp directory — silently
restored the per-click powershell.exe burst and per-operation MSIL emission this
work exists to remove, with computer use still working so nothing looked wrong.
Start failures are now retried, then cool down for 60s, then re-probed; the
client keeps the host so it can come back. Repeated post-answer crashes cool
down too, and a single reply no longer clears the failure count.
The one-shot bridge decided its execution-policy retry from a message that fell
back to stdout, so a window title containing "SecurityError" could replay a
non-idempotent operation — a double click, keystroke or paste — and stick the
session on Bypass. The retry now requires empty stdout and a matching stderr.
Serve-mode replies carry an echoed request id. Without one a single stray stdout
line would make every later response answer the previous request, acting on
stale element indexes with no error raised; a mismatch now kills the child.
Non-JSON noise is ignored rather than counted as the helper having answered.
Also: warnings reach the main process over the sidecar's IPC channel rather than
its piped, unread stdio; the child is watched on close rather than exit; dispose
latches so a queued request cannot respawn during teardown; and the host is
split into a serve channel and an availability policy to stay under max-lines.
* fix(computer-use): prove a helper never started before replaying its request
The retry that replaced the permanent-latch bug could deliver unrequested
input. send() re-sent the same request whenever the helper died without
replying, but "no reply came back" is not "the operation did not run":
runtime.ps1 synthesizes the click and only then builds the snapshot, which
allocates a full-window bitmap and walks the UIA tree — a native GDI+/UIA fault
there is uncatchable, and leaves the click already delivered. A deterministic
fault meant three clicks from the host plus a fourth from the one-shot bridge,
surfaced as a single failed operation.
-Serve now writes one {"ready":true} line after its Add-Type work and before
its first read, so "never started" is a fact rather than an inference. A request
is replayed only when the helper died before announcing. A runtime.ps1 that
predates the announcement — reachable through the provider path override — is
covered by an observation-tool allowlist until a ready line proves otherwise.
Host-detected aborts (timeout, desynchronised reply, oversized line) suppress
the exit handler, so they were bypassing failure accounting entirely and a
helper failing that way was respawned once per operation forever. They now
count and are logged.
Also stop charging twice for one outage: entering the cooldown resets the
failure count, so the first death after recovery no longer re-enters a full
cooldown and an interleaved workload cannot be stranded on the one-shot bridge.
* fix(computer-use): ignore a stdin write callback from a torn-down helper
stop() destroys stdin, so a write still queued at teardown calls back with
ERR_STREAM_DESTROYED. The callback carried no channel or request identity and
write() had no closed guard, so it ran abortChannel a second time: stopChannel
no-opped but recordFailure and the warning did not, charging two failures for
one operation and reaching the 3-strike cooldown at half the intended rate.
That feeds the same accounting that keeps a persistently broken helper from
respawning once per operation.
The same root also allowed a late callback landing after a replacement channel
existed to stop that channel and reject a different request with the previous
one's error. Node fires the destroyed-stream callback on the next tick, well
before a new request arrives, so the double-count is the reachable effect;
binding the callback closes both.
write() now drops payloads and error reports once closed, and the host ignores
any report whose channel or request id is no longer current.
* test(computer-use): pin each stale-write guard independently
The channel's closed guard and the host's request-identity check are redundant
by design, and the existing tests only failed when both were absent. Someone
deleting one, believing the other was the covered one, would have got a green
suite and a live regression — the same shape as a test that passes without the
fix it was written for.
Each is now pinned on its own. The channel's half is tested against the channel
directly: after stop() it takes no writes and reports no error from one already
queued, which the host cannot observe because it drops the channel at the same
moment. The host's half is pinned by the case the channel cannot see — a live
channel whose request was already answered, where backpressure delivers a write
callback for a request that is no longer pending.
Removing either guard alone now fails a test. Both carry a comment saying they
are deliberately redundant and separately pinned, so the next reader does not
have to rediscover this from the diff.
* ci(windows): run the computer-use runtime host suite in CI
The win32 suite only self-skips off Windows, so it passed vacuously in
every lane. Register it the way the cmd-shim suite is registered.
* fix(computer-use): time the runtime host cooldown on a monotonic clock
The start-failure cooldown was a wall-clock deadline, so a backwards step —
an NTP correction, a VM snapshot restore, a user changing the clock — left
`remainingCooldown()` returning the cooldown plus the whole step. A one-hour
step measured 3,660,000ms, and ten real minutes later still 3,060,000ms.
Nothing shortens it from there. Only `recordSuccess()` clears the cooldown on
a non-dispose path, and no request can reach a helper to succeed while it
holds, so every `send()` throws `runtime_host_unavailable` first. The host is
built with no `now` override and its lifecycle is a module-level singleton
that shuts down at process exit, so the latch held for the sidecar's life —
computer use kept working via the one-shot bridge while the per-click
powershell.exe burst this host exists to remove came back silently.
Store the instant the cooldown began and compare elapsed monotonic time,
following the two fixes in #17884. The field is `number | null` rather than
sentinel 0 because `performance.now()` legitimately returns 0.
Both new tests leave `now` unset, because the bug was in the default the host
picks and a test that injects a clock cannot see it.
* fix(computer-use): give a queued request its own deadline
The 30s request timeout was armed only in `sendOnce`, once a request reached
a helper. A request behind N timing-out ones therefore waited roughly N times
that with no deadline of its own: bounded, but the caller sees an `await` that
looks hung for minutes and gets no error to act on.
Move the serialization tail into its own class and arm a deadline at enqueue
time. Only the wait is bounded — a request that reaches a helper still gets
its full execution budget, so nothing that used to succeed now fails. An
expired request is dropped rather than sent late: the caller has already been
told it failed, and a click delivered after that is worse than no click.
The tail keeps its never-rejecting shape and chains on the turn rather than on
the raced promise, so a caller giving up early cannot release the next request
while its predecessor is still in flight.
* fix(computer-use): stop reading a locked file as an execution policy block
`UnauthorizedAccess` is the FullyQualifiedErrorId PowerShell reports for a
policy block, and it is also a strict prefix of `UnauthorizedAccessException`,
which .NET raises for any ordinary locked or ACL-denied file. The predicate
matched the token unanchored, so an AV scan holding runtime.ps1 or a locked
CSC temp directory was read as a policy block.
Two consequences, both bad. `escalateExecutionPolicy()` has no path back, so
one false match spent the rest of the session on `-ExecutionPolicy Bypass` —
the exact command line token this stack exists to stop emitting. And on the
one-shot path `isPolicyBlockedStart` re-runs the operation: one-shot mode
writes stdout only after the operation returns, so a crash partway through an
action is indistinguishable from a helper that never started, and the click
lands twice.
Measured on Windows against all three records, which the test carries verbatim
as fixtures:
policy/Restricted FullyQualifiedErrorId: UnauthorizedAccess
policy/RemoteSigned FullyQualifiedErrorId: UnauthorizedAccess
genuine access denied FullyQualifiedErrorId: UnauthorizedAccessException
`\b` is the whole discriminator: between `s` and `E` both sides are word
characters, so no boundary exists there and the exception cannot match.
Dropped two alternatives that measurement showed were wrong. `PSSecurityException`
never appears — the record surfaces through a native-command wrapper and reports
`ParentContainsErrorRecordException`. The prose is wrong three times over: it
differs by policy, it is localized, and PowerShell hard-wraps it mid-sentence.
Anchoring on the `FullyQualifiedErrorId:`/`CategoryInfo:` labels would be more
precise again, but those labels are localized where the values are not, so it
would lose a real block on a non-English host and strand it with no fallback.
Matching the values with word boundaries keeps both directions; a fixture with
translated labels pins it.
The escalation stays sticky. With the predicate correct, it only fires on a
machine that really does block, where re-probing the preferred policy would buy
a guaranteed failed spawn per operation.
* fix(computer-use): route a malformed request back to the request that caused it
`ConvertFrom-Json` throws before `$requestId` is read, so the serve loop
answered an unparseable request with an untagged error. On the client that is
not an error at all: `deliver()` sees no matching id, calls `abortChannel`,
kills the helper and charges a failure — and the helper's own message is
discarded. A parse failure was reported as a stream desync with no trace of
the real cause, and three of them walked into the 60s cooldown behind three
misleading "did not match" messages.
Recover the id from the raw line when the parse fails. No wire change: the
response shape is untouched and `BridgeResponse.requestId` already documents
this echo. It is the same shape the helper already returns for `not_a_tool`,
where the id survives because it is read before the operation runs. Both
mixed pairings degrade safely — a new script with an old client resolves the
error normally, and an old script with a new client still aborts, but now
reports what the helper said.
When the line is mangled past recovering an id, the desync abort is the honest
outcome, so keep it and carry the helper's text into it rather than replacing
it. A line the helper could not tag is usually the only account of the cause.
Proven against the real `runtime.ps1 -Serve`: the host can only write
well-formed JSON, so the parse-failure branch is unreachable through it and
the test drives the channel directly.
* fix(computer-use): keep the Bypass escalation only when Bypass actually works
AppLocker and WDAC constrained language mode raise PSSecurityException under
the same SecurityError category a real execution-policy block uses, so the
predicate matches them - correctly, on the evidence available. But those block
the script at parse time, which `-ExecutionPolicy Bypass` cannot lift. The
escalation was sticky unconditionally, so on a WDAC host we misdiagnosed,
retried, failed again, and then latched: every later command line carried the
most heavily weighted MDE token there is, on exactly the hardened, monitored
enterprise machine that is watching for it.
Treat the escalation as the diagnosis it is. A fallback that cannot start a
helper either disproves it - the policy was not what stopped the first attempt
- so revert to RemoteSigned instead of latching. When Bypass does start a
helper the diagnosis is confirmed and it stays sticky exactly as before, so a
genuinely Restricted machine still never pays a re-probe per operation.
The revert lands inside the outage rather than only at its end, so a
misdiagnosis costs one Bypass command line instead of one per attempt, and an
escalation that never proved itself does not outlive the cooldown that ends
the outage. Deliberately not a permanent "fallback is useless" flag: a Bypass
attempt that failed for a transient reason would then disable the fallback for
the session, which is the same latch in the other direction.
Only `runtime_host_unavailable` proves no helper started, so only that reverts;
a helper that started and then died proves Bypass works. That also makes the
policy branch reachable on a final attempt for the first time, so it now
rejects as unavailable rather than a generic error - that code is what routes
the operation to the one-shot bridge, which carries its own policy fallback,
and without it an all-blocked host would fail operations outright instead of
degrading. The pre-existing "reports itself unavailable when Bypass is also
refused" test pins that.
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
@@ -856,6 +856,7 @@ jobs:
|
||||
src/main/wsl/wsl-w1-w3-contract.test.ts
|
||||
src/shared/source-scan/source-tree-scan.test.ts
|
||||
src/main/cli/wsl-cli-powershell-boundary.test.ts
|
||||
src/main/computer/desktop-script-runtime-host.win32.test.ts
|
||||
src/main/cursor/hook-service.test.ts
|
||||
src/main/orca-profiles/profile-index-store.test.ts
|
||||
src/main/startup/windows-install-dir-acl-repair.win32.test.ts
|
||||
|
||||
@@ -228,6 +228,7 @@ const WINDOWS_PACKAGE_TESTS = [
|
||||
'src/main/wsl/wsl-w1-w3-contract.test.ts',
|
||||
'src/shared/source-scan/source-tree-scan.test.ts',
|
||||
'src/main/cli/wsl-cli-powershell-boundary.test.ts',
|
||||
'src/main/computer/desktop-script-runtime-host.win32.test.ts',
|
||||
'src/main/cursor/hook-service.test.ts',
|
||||
'src/main/orca-profiles/profile-index-store.test.ts',
|
||||
'src/main/startup/windows-install-dir-acl-repair.win32.test.ts',
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OperationPath
|
||||
[Parameter(Position = 0)]
|
||||
[string]$OperationPath,
|
||||
# Serve mode keeps one process alive so the Add-Type P/Invoke assembly below
|
||||
# is emitted once per session instead of once per operation.
|
||||
[switch]$Serve
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
# Progress records render to the host, which in serve mode is a pipe carrying
|
||||
# one JSON response per line; a stray record would desynchronise the stream.
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
[Console]::InputEncoding = $utf8NoBom
|
||||
[Console]::OutputEncoding = $utf8NoBom
|
||||
@@ -1313,9 +1319,56 @@ function Invoke-OrcaOperation($Operation) {
|
||||
[pscustomobject]@{ ok = $true; action = $action; snapshot = $snapshot }
|
||||
}
|
||||
|
||||
try {
|
||||
$operation = Read-OrcaOperation $OperationPath
|
||||
Write-OrcaJson (Invoke-OrcaOperation $operation)
|
||||
} catch {
|
||||
Write-OrcaJson ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message })
|
||||
function Invoke-OrcaServeLoop {
|
||||
# Announced before the first read, and after every Add-Type above: a caller
|
||||
# that never sees this line knows the helper cannot have read a request, let
|
||||
# alone synthesized a click, so replaying it is provably safe. Inferring that
|
||||
# from a missing response instead would replay operations that did run.
|
||||
[Console]::Out.WriteLine('{"ready":true}')
|
||||
[Console]::Out.Flush()
|
||||
# One NDJSON request per line in, one response per line out, until stdin closes.
|
||||
# Responses carry base64 screenshots and routinely exceed a megabyte; ReadLine
|
||||
# and the console writer are both length-bounded only by memory.
|
||||
while ($true) {
|
||||
$line = [Console]::In.ReadLine()
|
||||
if ($null -eq $line) { break }
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
$requestId = $null
|
||||
try {
|
||||
$operation = $line | ConvertFrom-Json
|
||||
$requestId = $operation.requestId
|
||||
$response = Invoke-OrcaOperation $operation
|
||||
} catch {
|
||||
$response = [pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message }
|
||||
# ConvertFrom-Json throws before the id is read, so recover it from the
|
||||
# raw line. An error the caller can match is delivered to the request
|
||||
# that caused it; an unmatched one only trips the caller's desync
|
||||
# guard, which kills this helper, charges a failure toward its cooldown
|
||||
# and discards the message below - so a malformed request would be
|
||||
# reported as a broken stream and its real cause never surface.
|
||||
if ($null -eq $requestId -and $line -match '"requestId"\s*:\s*(\d+)') {
|
||||
$requestId = [long]$Matches[1]
|
||||
}
|
||||
}
|
||||
# Echoed so the caller can prove which request a line answers; a reply it
|
||||
# cannot match is a desynchronised stream, not a usable response.
|
||||
if ($null -ne $requestId) {
|
||||
$response | Add-Member -NotePropertyName requestId -NotePropertyValue $requestId -Force
|
||||
}
|
||||
[Console]::Out.WriteLine((ConvertTo-Json $response -Depth 100 -Compress))
|
||||
[Console]::Out.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
if ($Serve) {
|
||||
Invoke-OrcaServeLoop
|
||||
} elseif ([string]::IsNullOrWhiteSpace($OperationPath)) {
|
||||
Write-OrcaJson ([pscustomobject]@{ ok = $false; error = "runtime.ps1 requires an operation path or -Serve" })
|
||||
} else {
|
||||
try {
|
||||
$operation = Read-OrcaOperation $OperationPath
|
||||
Write-OrcaJson (Invoke-OrcaOperation $operation)
|
||||
} catch {
|
||||
Write-OrcaJson ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isComputerSidecarDiagnostic,
|
||||
reportComputerDiagnostic
|
||||
} from './computer-sidecar-diagnostics'
|
||||
|
||||
describe('computer sidecar diagnostics', () => {
|
||||
const originalSend = process.send
|
||||
|
||||
afterEach(() => {
|
||||
process.send = originalSend
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends over IPC when running inside the sidecar', () => {
|
||||
const send = vi.fn((_message: unknown) => true)
|
||||
process.send = send as unknown as typeof process.send
|
||||
const console_ = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
reportComputerDiagnostic('fell back to Bypass')
|
||||
|
||||
// The sidecar's stdout is piped and never read, so this must not go there.
|
||||
expect(console_).not.toHaveBeenCalled()
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
kind: 'computer-sidecar-diagnostic',
|
||||
message: 'fell back to Bypass'
|
||||
})
|
||||
expect(isComputerSidecarDiagnostic(send.mock.calls[0][0])).toBe(true)
|
||||
})
|
||||
|
||||
it('logs directly when there is no IPC channel', () => {
|
||||
process.send = undefined
|
||||
const console_ = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
reportComputerDiagnostic('fell back to Bypass')
|
||||
|
||||
expect(console_).toHaveBeenCalledWith('[computer-use] fell back to Bypass')
|
||||
})
|
||||
|
||||
it('does not mistake a sidecar response for a diagnostic', () => {
|
||||
expect(isComputerSidecarDiagnostic({ id: 1, ok: true, result: {} })).toBe(false)
|
||||
expect(isComputerSidecarDiagnostic({ kind: 'computer-sidecar-diagnostic' })).toBe(false)
|
||||
expect(isComputerSidecarDiagnostic(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Warnings from the computer-use provider, routed to somewhere a human sees.
|
||||
*
|
||||
* Why not `console.warn`: the provider runs inside the forked sidecar, which
|
||||
* `sidecar-client.ts` starts with piped stdio that nothing ever reads. Anything
|
||||
* written there is discarded — including the only signal that a machine has
|
||||
* fallen back to `-ExecutionPolicy Bypass`, a state that persists for the
|
||||
* session. The sidecar has an IPC channel already, so the warning takes it.
|
||||
*/
|
||||
export type ComputerSidecarDiagnostic = {
|
||||
kind: 'computer-sidecar-diagnostic'
|
||||
message: string
|
||||
}
|
||||
|
||||
const DIAGNOSTIC_KIND = 'computer-sidecar-diagnostic'
|
||||
|
||||
export function isComputerSidecarDiagnostic(
|
||||
message: unknown
|
||||
): message is ComputerSidecarDiagnostic {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return false
|
||||
}
|
||||
const record = message as Record<string, unknown>
|
||||
return record.kind === DIAGNOSTIC_KIND && typeof record.message === 'string'
|
||||
}
|
||||
|
||||
export function reportComputerDiagnostic(message: string): void {
|
||||
if (process.send) {
|
||||
process.send({ kind: DIAGNOSTIC_KIND, message } satisfies ComputerSidecarDiagnostic)
|
||||
return
|
||||
}
|
||||
logComputerDiagnostic(message)
|
||||
}
|
||||
|
||||
/** The main-process end: how a sidecar's forwarded diagnostic is printed. */
|
||||
export function logComputerDiagnostic(message: string): void {
|
||||
console.warn(`[computer-use] ${message}`)
|
||||
}
|
||||
@@ -228,3 +228,17 @@ export function elementParam(
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools that only observe, and so may be safely re-sent to a fresh helper.
|
||||
*
|
||||
* Why an allowlist: a helper can die after running an operation but before
|
||||
* writing its reply, so a replayed mutation is a second click, keystroke or
|
||||
* paste. Only the observation tools are provably safe to repeat, and a tool
|
||||
* added later has to opt in rather than inherit a replay by default.
|
||||
*/
|
||||
const OBSERVATION_TOOLS = new Set(['handshake', 'list_apps', 'list_windows', 'get_app_state'])
|
||||
|
||||
export function isReplayableTool(tool: string): boolean {
|
||||
return OBSERVATION_TOOLS.has(tool)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,98 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary'
|
||||
import { reportComputerDiagnostic } from './computer-sidecar-diagnostics'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
import type { DesktopScriptPlatform } from './desktop-script-provider-paths'
|
||||
import {
|
||||
FALLBACK_WINDOWS_EXECUTION_POLICY,
|
||||
PREFERRED_WINDOWS_EXECUTION_POLICY,
|
||||
isExecutionPolicyBlocked,
|
||||
windowsPowerShellRuntimeArgs
|
||||
} from './windows-powershell-execution-policy'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const FORCE_KILL_GRACE_MS = 1_000
|
||||
|
||||
export function execBridge(
|
||||
export async function execBridge(
|
||||
platform: DesktopScriptPlatform,
|
||||
scriptPath: string,
|
||||
operationPath: string
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const command = platform === 'windows' ? 'powershell.exe' : 'python3'
|
||||
const args =
|
||||
platform === 'windows'
|
||||
? [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
scriptPath,
|
||||
operationPath
|
||||
]
|
||||
: [scriptPath, operationPath]
|
||||
if (platform !== 'windows') {
|
||||
return await mapped(runBridgeProcess('python3', [scriptPath, operationPath]))
|
||||
}
|
||||
const command = windowsPowerShellPath()
|
||||
try {
|
||||
return await runBridgeProcess(
|
||||
command,
|
||||
windowsPowerShellRuntimeArgs(scriptPath, PREFERRED_WINDOWS_EXECUTION_POLICY, [operationPath])
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isPolicyBlockedStart(error)) {
|
||||
throw error instanceof BridgeProcessFailure ? error.mapped : error
|
||||
}
|
||||
reportComputerDiagnostic(
|
||||
`bridge start blocked at ${PREFERRED_WINDOWS_EXECUTION_POLICY}; retrying once with ${FALLBACK_WINDOWS_EXECUTION_POLICY}`
|
||||
)
|
||||
return await mapped(
|
||||
runBridgeProcess(
|
||||
command,
|
||||
windowsPowerShellRuntimeArgs(scriptPath, FALLBACK_WINDOWS_EXECUTION_POLICY, [operationPath])
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unwrap the raw-stream carrier back into the error callers expect. */
|
||||
async function mapped(
|
||||
run: Promise<{ stdout: string; stderr: string }>
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
try {
|
||||
return await run
|
||||
} catch (error) {
|
||||
throw error instanceof BridgeProcessFailure ? error.mapped : error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a run that produced no stdout at all may be replayed.
|
||||
*
|
||||
* What the stdout guard covers: operations are not idempotent, and the response
|
||||
* embeds window titles and element names, so a snapshot that merely contains
|
||||
* the word "SecurityError" must not be read as a policy block and replayed as a
|
||||
* second click, keystroke or paste. It closes that injection route only.
|
||||
*
|
||||
* What it does not cover: one-shot mode runs the operation to completion and
|
||||
* writes stdout only afterwards, so stdout is empty for the whole action, not
|
||||
* just before it starts. A crash after the click but before the write looks
|
||||
* identical to a helper that never started. Nothing here can tell those apart —
|
||||
* only a policy pattern that cannot match a non-policy failure keeps the replay
|
||||
* off, which is why its `\b` is load-bearing rather than cosmetic.
|
||||
*/
|
||||
function isPolicyBlockedStart(error: unknown): error is BridgeProcessFailure {
|
||||
return (
|
||||
error instanceof BridgeProcessFailure &&
|
||||
!error.stdout.trim() &&
|
||||
isExecutionPolicyBlocked(error.stderr)
|
||||
)
|
||||
}
|
||||
|
||||
/** Carries the raw streams so the retry decision does not read a mapped message. */
|
||||
class BridgeProcessFailure extends Error {
|
||||
constructor(
|
||||
readonly stdout: string,
|
||||
readonly stderr: string,
|
||||
readonly mapped: RuntimeClientError
|
||||
) {
|
||||
super(mapped.message)
|
||||
this.name = 'BridgeProcessFailure'
|
||||
}
|
||||
}
|
||||
|
||||
function runBridgeProcess(
|
||||
command: string,
|
||||
args: readonly string[]
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof execFile> | null = null
|
||||
let settled = false
|
||||
@@ -76,7 +146,7 @@ export function execBridge(
|
||||
try {
|
||||
child = execFile(
|
||||
command,
|
||||
args,
|
||||
[...args],
|
||||
{
|
||||
env: process.env,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
@@ -86,11 +156,10 @@ export function execBridge(
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
const message = stderr.trim() || stdout.trim() || error.message
|
||||
finish(
|
||||
error.killed
|
||||
? new RuntimeClientError('action_timeout', message)
|
||||
: mapBridgeError(message)
|
||||
)
|
||||
const mapped = error.killed
|
||||
? new RuntimeClientError('action_timeout', message)
|
||||
: mapBridgeError(message)
|
||||
finish(new BridgeProcessFailure(stdout, stderr, mapped))
|
||||
return
|
||||
}
|
||||
finish(null, { stdout, stderr })
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
BridgeResponse,
|
||||
NativeActionMethod
|
||||
} from './desktop-script-provider-types'
|
||||
import { DesktopScriptRuntimeHost, isRuntimeHostUnavailable } from './desktop-script-runtime-host'
|
||||
import { DesktopScriptSnapshotStore } from './desktop-script-snapshot-store'
|
||||
import { normalizeBridgeApp, renderSnapshot } from './desktop-script-snapshot-rendering'
|
||||
import { normalizeComputerActionResult } from './computer-action-verification-normalization'
|
||||
@@ -51,12 +52,17 @@ export class DesktopScriptProviderClient {
|
||||
|
||||
constructor(
|
||||
private readonly platform: DesktopScriptPlatform = requiredPlatform(),
|
||||
private readonly scriptPath: string = requiredScriptPath()
|
||||
private readonly scriptPath: string = requiredScriptPath(),
|
||||
private readonly runtimeHost: DesktopScriptRuntimeHost | null = defaultRuntimeHost(
|
||||
platform,
|
||||
scriptPath
|
||||
)
|
||||
) {}
|
||||
|
||||
shutdown(): void {
|
||||
this.snapshotStore.clear()
|
||||
this.providerCapabilities = null
|
||||
this.runtimeHost?.dispose()
|
||||
}
|
||||
|
||||
async listApps(): Promise<ComputerListAppsResult> {
|
||||
@@ -203,6 +209,23 @@ export class DesktopScriptProviderClient {
|
||||
}
|
||||
|
||||
private async callBridge(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
const host = this.runtimeHost
|
||||
if (host) {
|
||||
try {
|
||||
return checkedBridgeResponse(await host.request(request), '')
|
||||
} catch (error) {
|
||||
// Only a helper that cannot start falls back; operation errors surface.
|
||||
// The host is kept: it re-probes after its cooldown, so a transient bad
|
||||
// spawn cannot strand the session on one powershell.exe per operation.
|
||||
if (!isRuntimeHostUnavailable(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
return await this.callOneShotBridge(request)
|
||||
}
|
||||
|
||||
private async callOneShotBridge(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
const operationDirectory = await mkdtemp(join(tmpdir(), 'orca-computer-use-'))
|
||||
const operationPath = join(operationDirectory, 'operation.json')
|
||||
try {
|
||||
@@ -217,10 +240,7 @@ export class DesktopScriptProviderClient {
|
||||
`desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw mapBridgeError(response.error ?? stderr)
|
||||
}
|
||||
return response
|
||||
return checkedBridgeResponse(response, stderr)
|
||||
} finally {
|
||||
await rm(operationDirectory, { force: true, recursive: true })
|
||||
}
|
||||
@@ -255,6 +275,21 @@ export class DesktopScriptProviderClient {
|
||||
}
|
||||
}
|
||||
|
||||
function checkedBridgeResponse(response: BridgeResponse, stderr: string): BridgeResponse {
|
||||
if (!response.ok) {
|
||||
throw mapBridgeError(response.error ?? stderr)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// Why Windows only: the Linux provider is a python3 one-shot with no serve mode.
|
||||
function defaultRuntimeHost(
|
||||
platform: DesktopScriptPlatform,
|
||||
scriptPath: string
|
||||
): DesktopScriptRuntimeHost | null {
|
||||
return platform === 'windows' ? new DesktopScriptRuntimeHost(scriptPath) : null
|
||||
}
|
||||
|
||||
function requiredPlatform(): DesktopScriptPlatform {
|
||||
const platform = desktopScriptPlatform()
|
||||
if (!platform) {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
bridgeProcessArgs,
|
||||
createDesktopScriptProviderClient,
|
||||
expectDesktopProviderSubprocessStartCount,
|
||||
mockBridgeProcessFailure,
|
||||
mockBridgeResponse,
|
||||
resetDesktopScriptProviderTestHarness,
|
||||
sampleCapabilities
|
||||
} from './desktop-script-provider-test-harness'
|
||||
import type { BridgeResponse } from './desktop-script-provider-types'
|
||||
import type { DesktopScriptRuntimeHost } from './desktop-script-runtime-host'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
const POLICY_STDERR =
|
||||
'File runtime.ps1 cannot be loaded because running scripts is disabled on this system. + CategoryInfo : SecurityError'
|
||||
|
||||
function fakeRuntimeHost(request: DesktopScriptRuntimeHost['request']) {
|
||||
const dispose = vi.fn()
|
||||
return { host: { request, dispose } as unknown as DesktopScriptRuntimeHost, dispose }
|
||||
}
|
||||
|
||||
describe('desktop script provider runtime host routing', () => {
|
||||
afterEach(resetDesktopScriptProviderTestHarness)
|
||||
|
||||
it('serves Windows operations from the runtime host without spawning a one-shot bridge', async () => {
|
||||
const request = vi.fn(
|
||||
async () => ({ ok: true, capabilities: sampleCapabilities() }) as BridgeResponse
|
||||
)
|
||||
const { host } = fakeRuntimeHost(request)
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host)
|
||||
|
||||
await expect(client.capabilities()).resolves.toMatchObject({ platform: 'linux' })
|
||||
expect(request).toHaveBeenCalledWith({ tool: 'handshake' })
|
||||
expectDesktopProviderSubprocessStartCount(0)
|
||||
})
|
||||
|
||||
it('maps runtime host operation failures without falling back to the one-shot bridge', async () => {
|
||||
const { host } = fakeRuntimeHost(
|
||||
vi.fn(async () => ({ ok: false, error: 'appBlocked("1Password")' }) as BridgeResponse)
|
||||
)
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host)
|
||||
|
||||
await expect(client.listApps()).rejects.toMatchObject({ code: 'app_blocked' })
|
||||
expectDesktopProviderSubprocessStartCount(0)
|
||||
})
|
||||
|
||||
it('degrades to the one-shot bridge for the operations a host cannot serve', async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new RuntimeClientError('runtime_host_unavailable', 'could not start')
|
||||
})
|
||||
const { host, dispose } = fakeRuntimeHost(request as never)
|
||||
mockBridgeResponse({ ok: true, apps: [{ name: 'Notepad', pid: 42 }] })
|
||||
mockBridgeResponse({ ok: true, apps: [{ name: 'Notepad', pid: 42 }] })
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host)
|
||||
|
||||
await expect(client.listApps()).resolves.toMatchObject({ apps: [{ pid: 42 }] })
|
||||
await client.listApps()
|
||||
|
||||
// The host is kept and asked again: it owns its own cooldown, so one bad
|
||||
// spawn must not stand the session down to a powershell.exe per click.
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
expectDesktopProviderSubprocessStartCount(2)
|
||||
})
|
||||
|
||||
it('returns to the runtime host once it recovers', async () => {
|
||||
let healthy = false
|
||||
const request = vi.fn(async () => {
|
||||
if (!healthy) {
|
||||
throw new RuntimeClientError('runtime_host_unavailable', 'could not start')
|
||||
}
|
||||
return { ok: true, apps: [] } as BridgeResponse
|
||||
})
|
||||
const { host } = fakeRuntimeHost(request)
|
||||
mockBridgeResponse({ ok: true, apps: [] })
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host)
|
||||
|
||||
await client.listApps()
|
||||
expectDesktopProviderSubprocessStartCount(1)
|
||||
|
||||
healthy = true
|
||||
await expect(client.listApps()).resolves.toEqual({ apps: [] })
|
||||
expectDesktopProviderSubprocessStartCount(1)
|
||||
})
|
||||
|
||||
it('runs the one-shot bridge under RemoteSigned and falls back to Bypass once', async () => {
|
||||
mockBridgeProcessFailure(POLICY_STDERR)
|
||||
mockBridgeResponse({ ok: true, apps: [] })
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1')
|
||||
|
||||
await expect(client.listApps()).resolves.toEqual({ apps: [] })
|
||||
expectDesktopProviderSubprocessStartCount(2)
|
||||
expect(bridgeProcessArgs(0)).toContain('-NoLogo')
|
||||
expect(bridgeProcessArgs(0)).toContain('RemoteSigned')
|
||||
expect(bridgeProcessArgs(0)).not.toContain('Bypass')
|
||||
expect(bridgeProcessArgs(1)).toContain('Bypass')
|
||||
})
|
||||
|
||||
it('does not retry the one-shot bridge for a non-policy failure', async () => {
|
||||
mockBridgeProcessFailure('No top-level UI Automation window is available for Notepad')
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1')
|
||||
|
||||
await expect(client.listApps()).rejects.toMatchObject({ code: 'window_not_found' })
|
||||
expectDesktopProviderSubprocessStartCount(1)
|
||||
})
|
||||
|
||||
it('never replays an operation whose own output merely mentions a policy error', async () => {
|
||||
// Window titles and element names are user-controlled text that lands in
|
||||
// stdout; matching them would double a click, a keystroke or a paste.
|
||||
mockBridgeProcessFailure({
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
snapshot: { windowTitle: 'SecurityError - UnauthorizedAccess.log - Notepad' }
|
||||
}),
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1')
|
||||
|
||||
await expect(client.listApps()).rejects.toBeInstanceOf(Error)
|
||||
expectDesktopProviderSubprocessStartCount(1)
|
||||
})
|
||||
|
||||
it('keeps Linux on the one-shot python bridge with no execution policy flags', async () => {
|
||||
mockBridgeResponse({ ok: true, apps: [] })
|
||||
|
||||
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
|
||||
|
||||
await expect(client.listApps()).resolves.toEqual({ apps: [] })
|
||||
expect(bridgeProcessArgs(0)).toEqual(['/tmp/runtime.py', expect.any(String)])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, vi } from 'vitest'
|
||||
import type { DesktopScriptRuntimeHost } from './desktop-script-runtime-host'
|
||||
|
||||
const { execFileMock, operationFiles, mkdtempMock, rmMock, writeFileMock } = vi.hoisted(() => {
|
||||
const files = new Map<string, string>()
|
||||
@@ -23,12 +24,14 @@ vi.mock('fs/promises', () => ({
|
||||
writeFile: writeFileMock
|
||||
}))
|
||||
|
||||
/** Builds a client on the one-shot bridge; pass a host to exercise serve mode. */
|
||||
export async function createDesktopScriptProviderClient(
|
||||
platform: 'linux' | 'windows',
|
||||
executablePath: string
|
||||
executablePath: string,
|
||||
runtimeHost: DesktopScriptRuntimeHost | null = null
|
||||
) {
|
||||
const { DesktopScriptProviderClient } = await import('./desktop-script-provider-client')
|
||||
return new DesktopScriptProviderClient(platform, executablePath)
|
||||
return new DesktopScriptProviderClient(platform, executablePath, runtimeHost)
|
||||
}
|
||||
|
||||
export function resetDesktopScriptProviderTestHarness(): void {
|
||||
@@ -77,6 +80,19 @@ export function mockBridgeResponse(
|
||||
})
|
||||
}
|
||||
|
||||
export function mockBridgeProcessFailure(streams: string | { stdout?: string; stderr?: string }) {
|
||||
const { stdout = '', stderr = '' } = typeof streams === 'string' ? { stderr: streams } : streams
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
const done = callback as (error: Error | null, stdout: string, stderr: string) => void
|
||||
done(new Error('Command failed'), stdout, stderr)
|
||||
return null as never
|
||||
})
|
||||
}
|
||||
|
||||
export function bridgeProcessArgs(call: number): string[] {
|
||||
return (execFileMock.mock.calls[call]?.[1] ?? []) as string[]
|
||||
}
|
||||
|
||||
export function sampleBridgeSnapshot(name: string, value: string) {
|
||||
return {
|
||||
app: { name, bundleIdentifier: name, pid: 100 },
|
||||
|
||||
@@ -101,6 +101,8 @@ export type BridgeWindow = {
|
||||
|
||||
export type BridgeResponse = {
|
||||
ok: boolean
|
||||
/** Echo of BridgeRequest.requestId; set only on the persistent serve path. */
|
||||
requestId?: number
|
||||
error?: string
|
||||
capabilities?: ComputerProviderCapabilities
|
||||
apps?: {
|
||||
@@ -122,6 +124,8 @@ export type BridgeResponse = {
|
||||
|
||||
export type BridgeRequest = {
|
||||
tool: string
|
||||
/** Correlates a serve-mode reply with its request; the one-shot path omits it. */
|
||||
requestId?: number
|
||||
app?: string
|
||||
element?: BridgeElement
|
||||
fromElement?: BridgeElement
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
/**
|
||||
* Serializes operations onto one helper and bounds how long one may wait its
|
||||
* turn.
|
||||
*
|
||||
* Why the wait needs its own deadline: the in-flight timeout is armed only once
|
||||
* a request reaches a helper, so a request behind N timing-out ones waited N
|
||||
* times that timeout with no deadline of its own — bounded, but the caller sees
|
||||
* an `await` that looks hung for minutes and gets no error to act on.
|
||||
*
|
||||
* Why only the wait: a request that reaches a helper still gets its full
|
||||
* execution budget. A single deadline covering both would fail operations that
|
||||
* queued briefly and would otherwise have succeeded.
|
||||
*/
|
||||
export class DesktopScriptRequestQueue {
|
||||
/**
|
||||
* Never rejects: downstream turns chain onto it, and a rejection here would
|
||||
* be delivered to whichever request happened to queue behind the failure.
|
||||
*/
|
||||
private tail: Promise<void> | null = null
|
||||
|
||||
constructor(
|
||||
private readonly waitTimeoutMs: number,
|
||||
/** Called when the queue empties, so the host can arm its idle shutdown. */
|
||||
private readonly onDrained: () => void
|
||||
) {}
|
||||
|
||||
enqueue<T>(run: () => Promise<T>): Promise<T> {
|
||||
const queued = this.tail
|
||||
if (!queued) {
|
||||
return this.track(run())
|
||||
}
|
||||
let expiry: RuntimeClientError | null = null
|
||||
let waitTimer: NodeJS.Timeout | undefined
|
||||
const waited = new Promise<never>((_resolve, reject) => {
|
||||
waitTimer = setTimeout(() => {
|
||||
expiry = new RuntimeClientError(
|
||||
'action_timeout',
|
||||
`desktop provider timed out after ${this.waitTimeoutMs}ms waiting for earlier operations`
|
||||
)
|
||||
reject(expiry)
|
||||
}, this.waitTimeoutMs)
|
||||
waitTimer.unref?.()
|
||||
})
|
||||
// An abandoned request is never handed to a helper. The caller has already
|
||||
// been told it failed, and a click delivered after that is worse than none.
|
||||
const turn = (): Promise<T> => {
|
||||
clearTimeout(waitTimer)
|
||||
return expiry ? Promise.reject(expiry) : run()
|
||||
}
|
||||
// The tail chains on the turn, not on the race: a caller giving up early
|
||||
// must not release the next request while this one's predecessor is still
|
||||
// in flight.
|
||||
return Promise.race([waited, this.track(queued.then(turn, turn))])
|
||||
}
|
||||
|
||||
private track<T>(result: Promise<T>): Promise<T> {
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
this.tail = tail
|
||||
void tail.finally(() => {
|
||||
if (this.tail !== tail) {
|
||||
return
|
||||
}
|
||||
this.tail = null
|
||||
this.onDrained()
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
FALLBACK_WINDOWS_EXECUTION_POLICY,
|
||||
PREFERRED_WINDOWS_EXECUTION_POLICY,
|
||||
type WindowsExecutionPolicy
|
||||
} from './windows-powershell-execution-policy'
|
||||
|
||||
/**
|
||||
* Consecutive child failures before the helper is believed dead, and how long
|
||||
* the one-shot bridge covers for it afterwards.
|
||||
*
|
||||
* Why not a latch: every plausible cause is transient — a Defender scan touching
|
||||
* the script mid-launch, a locked CSC temp directory failing one `Add-Type`,
|
||||
* momentary memory pressure. Giving up permanently silently restores the
|
||||
* per-click process burst the host exists to remove, and computer use keeps
|
||||
* working throughout, so nothing looks wrong while the MDE signature returns.
|
||||
*/
|
||||
export const MAX_START_ATTEMPTS = 3
|
||||
export const START_FAILURE_COOLDOWN_MS = 60_000
|
||||
|
||||
/**
|
||||
* Why not `Date.now`: an NTP correction, a VM snapshot restore or a user changing
|
||||
* the clock steps the wall clock backwards, which extended the cooldown by the
|
||||
* size of the step. Nothing shortens it from there — only `recordSuccess` clears
|
||||
* it, and no request can reach a helper to succeed while it holds — so a one-hour
|
||||
* step disabled the persistent helper for the life of the sidecar, silently
|
||||
* restoring the per-click process burst. Elapsed monotonic time cannot go
|
||||
* backwards.
|
||||
*/
|
||||
const monotonicNowMs = (): number => performance.now()
|
||||
|
||||
/**
|
||||
* Whether the persistent helper is currently believed usable, and the execution
|
||||
* policy it should be started under.
|
||||
*
|
||||
* Split from the host so the recovery rules are readable on their own: they are
|
||||
* what stands between a transient bad spawn and a session that silently spends
|
||||
* the rest of its life on one powershell.exe per click.
|
||||
*/
|
||||
export class RuntimeHostAvailability {
|
||||
private policy: WindowsExecutionPolicy = PREFERRED_WINDOWS_EXECUTION_POLICY
|
||||
private retryUnderFallbackPolicy = false
|
||||
private consecutiveFailures = 0
|
||||
private consecutiveSuccesses = 0
|
||||
/** Null, not 0, for "no cooldown": `performance.now()` legitimately returns 0. */
|
||||
private cooldownStartedAtMs: number | null = null
|
||||
/**
|
||||
* Set while the escalated policy has yet to start a helper, so a wrong
|
||||
* diagnosis can be taken back.
|
||||
*
|
||||
* Why it can be wrong: AppLocker and WDAC constrained language mode raise
|
||||
* PSSecurityException under the same SecurityError category a policy block
|
||||
* uses, but they refuse the script at parse time, which `Bypass` cannot lift.
|
||||
* Latching there would spend the session putting the most heavily weighted
|
||||
* MDE token on every command line, on exactly the hardened hosts watching
|
||||
* for it.
|
||||
*/
|
||||
private fallbackPolicyUnproven = false
|
||||
|
||||
constructor(
|
||||
private readonly cooldownMs: number,
|
||||
/** Public so the host can report its own start attempts to the same sink. */
|
||||
readonly warn: (message: string) => void,
|
||||
/** Overridden only by tests; the default must stay monotonic. */
|
||||
private readonly now: () => number = monotonicNowMs
|
||||
) {}
|
||||
|
||||
get executionPolicy(): WindowsExecutionPolicy {
|
||||
return this.policy
|
||||
}
|
||||
|
||||
get policyRetryPending(): boolean {
|
||||
return this.retryUnderFallbackPolicy
|
||||
}
|
||||
|
||||
get atPreferredPolicy(): boolean {
|
||||
return this.policy === PREFERRED_WINDOWS_EXECUTION_POLICY
|
||||
}
|
||||
|
||||
/** Milliseconds left before the host may try a helper again; 0 when it may. */
|
||||
remainingCooldown(): number {
|
||||
if (this.cooldownStartedAtMs === null) {
|
||||
return 0
|
||||
}
|
||||
// Elapsed since the cooldown began, never a stored deadline: a deadline is
|
||||
// only as trustworthy as the clock it was computed against.
|
||||
return Math.max(0, Math.ceil(this.cooldownMs - (this.now() - this.cooldownStartedAtMs)))
|
||||
}
|
||||
|
||||
requestPolicyRetry(): void {
|
||||
this.retryUnderFallbackPolicy = true
|
||||
}
|
||||
|
||||
escalateExecutionPolicy(): void {
|
||||
this.retryUnderFallbackPolicy = false
|
||||
this.policy = FALLBACK_WINDOWS_EXECUTION_POLICY
|
||||
this.fallbackPolicyUnproven = true
|
||||
// Sticky once proven: a genuinely Restricted machine would otherwise pay a
|
||||
// guaranteed failed spawn per operation. Only a helper that produced no
|
||||
// output at all can reach here, so a snapshot cannot talk the host into it.
|
||||
this.warn(
|
||||
`runtime host start blocked at ${PREFERRED_WINDOWS_EXECUTION_POLICY}; trying ${FALLBACK_WINDOWS_EXECUTION_POLICY}`
|
||||
)
|
||||
}
|
||||
|
||||
/** A helper started under the current policy, so the policy is the right one. */
|
||||
confirmExecutionPolicy(): void {
|
||||
this.fallbackPolicyUnproven = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo an escalation the fallback never justified.
|
||||
*
|
||||
* The escalation is a diagnosis, and a fallback that cannot start a helper
|
||||
* either disproves it: the policy was not what stopped the first attempt. Go
|
||||
* back rather than latch, so a re-probe can escalate again later if the real
|
||||
* cause clears. Re-probing costs one spawn per outage, which the failure
|
||||
* count and its cooldown already bound, and never latching is the whole point
|
||||
* of this class.
|
||||
*/
|
||||
abandonUnprovenFallback(): void {
|
||||
if (!this.fallbackPolicyUnproven) {
|
||||
return
|
||||
}
|
||||
this.fallbackPolicyUnproven = false
|
||||
this.policy = PREFERRED_WINDOWS_EXECUTION_POLICY
|
||||
this.warn(
|
||||
`${FALLBACK_WINDOWS_EXECUTION_POLICY} did not start a helper either, so the execution policy was not the cause; returning to ${PREFERRED_WINDOWS_EXECUTION_POLICY}`
|
||||
)
|
||||
}
|
||||
|
||||
recordFailure(): void {
|
||||
this.consecutiveSuccesses = 0
|
||||
this.consecutiveFailures++
|
||||
}
|
||||
|
||||
/** True once a helper has died often enough that respawning is just thrash. */
|
||||
get exhausted(): boolean {
|
||||
return this.consecutiveFailures >= MAX_START_ATTEMPTS
|
||||
}
|
||||
|
||||
recordSuccess(): void {
|
||||
this.consecutiveSuccesses++
|
||||
this.fallbackPolicyUnproven = false
|
||||
// Why a clean run and not a single reply: a helper that answers one
|
||||
// operation and dies on the next would otherwise reset the count forever,
|
||||
// and respawn once per operation — the exact burst the host removes.
|
||||
if (this.consecutiveSuccesses >= MAX_START_ATTEMPTS) {
|
||||
this.consecutiveFailures = 0
|
||||
}
|
||||
if (this.cooldownStartedAtMs === null) {
|
||||
return
|
||||
}
|
||||
this.cooldownStartedAtMs = null
|
||||
this.warn('runtime host recovered; operations are served by the persistent helper again')
|
||||
}
|
||||
|
||||
enterCooldown(): void {
|
||||
// An escalation that never started a helper must not outlive the outage it
|
||||
// was guessed from; the next one re-diagnoses from the preferred policy.
|
||||
this.abandonUnprovenFallback()
|
||||
const failures = this.consecutiveFailures
|
||||
this.cooldownStartedAtMs = this.now()
|
||||
// The wait is the penalty; leaving the count at the limit would charge twice
|
||||
// and let the first death after recovery re-enter a full cooldown, so an
|
||||
// interleaved workload would spend its life on the one-shot bridge.
|
||||
this.consecutiveFailures = 0
|
||||
this.consecutiveSuccesses = 0
|
||||
this.warn(
|
||||
`runtime host unavailable after ${failures} consecutive failures; falling back to one powershell.exe per operation for ${this.cooldownMs}ms`
|
||||
)
|
||||
}
|
||||
|
||||
clearCooldown(): void {
|
||||
this.cooldownStartedAtMs = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessSpec } from '../../shared/child-process/process-spec'
|
||||
import type { RuntimeChildProcess } from './desktop-script-serve-channel'
|
||||
import { DesktopScriptRuntimeHost, isRuntimeHostUnavailable } from './desktop-script-runtime-host'
|
||||
|
||||
const POLICY_ERROR =
|
||||
'File runtime.ps1 cannot be loaded because running scripts\nis disabled on this system.\n + CategoryInfo : SecurityError'
|
||||
|
||||
class FakeRuntimeChild extends EventEmitter {
|
||||
readonly stdout = new EventEmitter()
|
||||
readonly stderr = new EventEmitter()
|
||||
readonly writes: string[] = []
|
||||
killed = false
|
||||
stdinEnded = false
|
||||
/** Holds write callbacks so a late stdin failure can be fired deliberately. */
|
||||
deferWrites = false
|
||||
private readonly pendingWrites: ((error?: Error | null) => void)[] = []
|
||||
|
||||
readonly stdin = {
|
||||
write: (chunk: string, callback?: (error?: Error | null) => void): boolean => {
|
||||
this.writes.push(chunk)
|
||||
if (this.deferWrites) {
|
||||
if (callback) {
|
||||
this.pendingWrites.push(callback)
|
||||
}
|
||||
return true
|
||||
}
|
||||
callback?.(null)
|
||||
return true
|
||||
},
|
||||
end: (): void => {
|
||||
this.stdinEnded = true
|
||||
},
|
||||
on: (): void => {}
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
this.killed = true
|
||||
return true
|
||||
}
|
||||
|
||||
/** What a destroyed stdin does to writes still queued at teardown. */
|
||||
failQueuedWrites(): void {
|
||||
for (const callback of this.pendingWrites.splice(0)) {
|
||||
callback(new Error('ERR_STREAM_DESTROYED'))
|
||||
}
|
||||
}
|
||||
|
||||
/** Fail one queued write, leaving later ones outstanding. */
|
||||
failQueuedWrite(index: number): void {
|
||||
this.pendingWrites.splice(index, 1)[0](new Error('EPIPE'))
|
||||
}
|
||||
|
||||
/** Requests written to this child, decoded. */
|
||||
requests(): Record<string, unknown>[] {
|
||||
return this.writes.map((line) => JSON.parse(line) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** The id the host is currently waiting on, so replies can echo it. */
|
||||
pendingId(): number {
|
||||
return this.requests().at(-1)?.requestId as number
|
||||
}
|
||||
|
||||
/** The announcement the real serve loop writes before its first read. */
|
||||
ready(): void {
|
||||
this.write('{"ready":true}\n')
|
||||
}
|
||||
|
||||
respond(response: Record<string, unknown>, requestId = this.pendingId()): void {
|
||||
this.write(`${JSON.stringify({ ...response, requestId })}\n`)
|
||||
}
|
||||
|
||||
write(raw: string): void {
|
||||
this.stdout.emit('data', Buffer.from(raw, 'utf8'))
|
||||
}
|
||||
|
||||
exit(code: number | null, stderr = ''): void {
|
||||
if (stderr) {
|
||||
this.stderr.emit('data', Buffer.from(stderr, 'utf8'))
|
||||
}
|
||||
this.emit('close', code, null)
|
||||
}
|
||||
}
|
||||
|
||||
function createHost(
|
||||
options: {
|
||||
idleShutdownMs?: number
|
||||
requestTimeoutMs?: number
|
||||
cooldownMs?: number
|
||||
now?: () => number
|
||||
deferWrites?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const children: FakeRuntimeChild[] = []
|
||||
const specs: ProcessSpec[] = []
|
||||
const warnings: string[] = []
|
||||
const host = new DesktopScriptRuntimeHost('C:\\orca\\runtime.ps1', {
|
||||
...options,
|
||||
powerShellPath: () => 'C:\\Windows\\System32\\powershell.exe',
|
||||
warn: (message) => warnings.push(message),
|
||||
spawn: (spec) => {
|
||||
specs.push(spec)
|
||||
const child = new FakeRuntimeChild()
|
||||
child.deferWrites = options.deferWrites === true
|
||||
children.push(child)
|
||||
return child as unknown as RuntimeChildProcess
|
||||
}
|
||||
})
|
||||
return { host, children, specs, warnings }
|
||||
}
|
||||
|
||||
/** Let the host's queue microtasks drain so the next request reaches its child. */
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 6; index++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** The wait the host reported, read back out of its refusal message. */
|
||||
function remainingCooldownMs(error: Error | null): number {
|
||||
const match = /retrying the runtime host in (\d+)ms/.exec(error?.message ?? '')
|
||||
return match ? Number(match[1]) : Number.NaN
|
||||
}
|
||||
|
||||
/** Kill each helper the host starts, until it stops starting them. */
|
||||
async function failEveryStart(children: FakeRuntimeChild[], stderr: string): Promise<void> {
|
||||
for (let index = 0; index < 8; index++) {
|
||||
if (index >= children.length) {
|
||||
return
|
||||
}
|
||||
children[index].exit(1, stderr)
|
||||
await settle()
|
||||
}
|
||||
}
|
||||
|
||||
describe('DesktopScriptRuntimeHost', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('starts one helper for many operations and never writes an operation file', async () => {
|
||||
const { host, children, specs } = createHost()
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await expect(first).resolves.toMatchObject({ ok: true })
|
||||
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const next = host.request({ tool: 'click', app: 'Notepad' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, action: { path: 'synthetic' } })
|
||||
await expect(next).resolves.toMatchObject({ ok: true })
|
||||
}
|
||||
|
||||
expect(children).toHaveLength(1)
|
||||
expect(children[0].requests()).toHaveLength(6)
|
||||
expect(specs[0].args).toEqual([
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'RemoteSigned',
|
||||
'-File',
|
||||
'C:\\orca\\runtime.ps1',
|
||||
'-Serve'
|
||||
])
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('serializes requests so only one operation is ever in flight', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const first = host.request({ tool: 'click', app: 'A' })
|
||||
const second = host.request({ tool: 'click', app: 'B' })
|
||||
await settle()
|
||||
|
||||
expect(children[0].requests()).toEqual([{ tool: 'click', app: 'A', requestId: 1 }])
|
||||
|
||||
children[0].respond({ ok: true, action: { path: 'synthetic' } })
|
||||
await expect(first).resolves.toMatchObject({ ok: true })
|
||||
await settle()
|
||||
|
||||
expect(children[0].requests()).toHaveLength(2)
|
||||
children[0].respond({ ok: true, action: { path: 'accessibility' } })
|
||||
await expect(second).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('strips the echoed id from the response it hands back', async () => {
|
||||
const { host, children } = createHost()
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
|
||||
await expect(promise).resolves.toEqual({ ok: true, capabilities: {} })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('reassembles a response split across chunks, including a split code point', async () => {
|
||||
const { host, children } = createHost()
|
||||
const promise = host.request({ tool: 'get_app_state', app: 'Editor' })
|
||||
await settle()
|
||||
|
||||
const payload = Buffer.from(
|
||||
`${JSON.stringify({ ok: true, snapshot: { app: 'né' }, requestId: 1 })}\r\n`,
|
||||
'utf8'
|
||||
)
|
||||
const split = payload.indexOf(Buffer.from('é', 'utf8')) + 1
|
||||
children[0].stdout.emit('data', payload.subarray(0, split))
|
||||
children[0].stdout.emit('data', payload.subarray(split))
|
||||
|
||||
await expect(promise).resolves.toEqual({ ok: true, snapshot: { app: 'né' } })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('kills the helper rather than answering a request with another reply', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
// A stray line would otherwise shift every later response by one.
|
||||
children[0].respond({ ok: true, capabilities: {} }, 999)
|
||||
|
||||
await expect(first).rejects.toThrow(/did not match the pending request/)
|
||||
expect(children[0].killed).toBe(true)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('kills the helper when an unsolicited line arrives with nothing pending', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await first
|
||||
|
||||
children[0].write(`${JSON.stringify({ ok: true, requestId: 77 })}\n`)
|
||||
expect(children[0].killed).toBe(true)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('times out a wedged operation and starts a fresh helper for the next one', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { host, children } = createHost({ requestTimeoutMs: 30_000 })
|
||||
|
||||
const promise = host.request({ tool: 'click', app: 'Frozen' })
|
||||
await settle()
|
||||
await vi.advanceTimersByTimeAsync(30_001)
|
||||
|
||||
await expect(promise).rejects.toMatchObject({ code: 'action_timeout' })
|
||||
expect(children[0].killed).toBe(true)
|
||||
|
||||
const next = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(2)
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await expect(next).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('rejects the in-flight request when a working helper crashes, then restarts', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await first
|
||||
|
||||
const second = host.request({ tool: 'click', app: 'Notepad' })
|
||||
await settle()
|
||||
children[0].exit(1, 'boom')
|
||||
|
||||
await expect(second).rejects.toMatchObject({ code: 'accessibility_error' })
|
||||
await expect(second).rejects.toThrow(/runtime host exited/)
|
||||
|
||||
const third = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(2)
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await expect(third).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('stops respawning a helper that dies on every second operation', async () => {
|
||||
let clock = 1_000
|
||||
const { host, children } = createHost({ cooldownMs: 60_000, now: () => clock })
|
||||
|
||||
// One good answer per helper is exactly the pattern that used to respawn
|
||||
// forever: the success reset the failure count before it could ever trip.
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const good = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children.at(-1)?.respond({ ok: true, capabilities: {} })
|
||||
await expect(good).resolves.toMatchObject({ ok: true })
|
||||
await settle()
|
||||
|
||||
const crash = host.request({ tool: 'click', app: 'Crashy' })
|
||||
await settle()
|
||||
children.at(-1)?.exit(1, 'boom')
|
||||
await expect(crash).rejects.toThrow(/runtime host exited/)
|
||||
await settle()
|
||||
}
|
||||
|
||||
const spawned = children.length
|
||||
await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
expect(children).toHaveLength(spawned)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('keeps serving a healthy helper after an isolated crash', async () => {
|
||||
const { host, children } = createHost({ cooldownMs: 60_000 })
|
||||
|
||||
const crashed = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await crashed
|
||||
const second = host.request({ tool: 'click', app: 'Notepad' })
|
||||
await settle()
|
||||
children[0].exit(1, 'boom')
|
||||
await expect(second).rejects.toThrow(/runtime host exited/)
|
||||
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const next = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children.at(-1)?.respond({ ok: true, capabilities: {} })
|
||||
await expect(next).resolves.toMatchObject({ ok: true })
|
||||
}
|
||||
|
||||
// A clean run clears the count, so one bad helper cannot degrade a good one.
|
||||
expect(children).toHaveLength(2)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('stops respawning a helper that keeps answering the wrong request', async () => {
|
||||
let clock = 1_000
|
||||
const { host, children } = createHost({ cooldownMs: 60_000, now: () => clock })
|
||||
|
||||
// Desync is host-detected, so it bypassed the exit handler entirely: without
|
||||
// its own accounting this respawned once per operation, forever.
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
const child = children.at(-1)
|
||||
child?.respond({ ok: true, capabilities: {} }, child.pendingId() + 500)
|
||||
await expect(promise).rejects.toThrow(/did not match the pending request/)
|
||||
await settle()
|
||||
}
|
||||
|
||||
const spawned = children.length
|
||||
await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
expect(children).toHaveLength(spawned)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('stops respawning a helper that times out on every operation', async () => {
|
||||
vi.useFakeTimers()
|
||||
let clock = 1_000
|
||||
const { host, children } = createHost({
|
||||
requestTimeoutMs: 1_000,
|
||||
cooldownMs: 60_000,
|
||||
now: () => clock
|
||||
})
|
||||
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const promise = host.request({ tool: 'get_app_state', app: 'Frozen' })
|
||||
await settle()
|
||||
await vi.advanceTimersByTimeAsync(1_001)
|
||||
await expect(promise).rejects.toMatchObject({ code: 'action_timeout' })
|
||||
await settle()
|
||||
}
|
||||
|
||||
const spawned = children.length
|
||||
await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
expect(children).toHaveLength(spawned)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('never re-sends a mutation to a fresh helper after a pre-answer death', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const promise = host.request({ tool: 'click', app: 'Notepad', x: 10, y: 10 })
|
||||
await settle()
|
||||
children[0].exit(1, 'Add-Type : Cannot access the temporary directory')
|
||||
|
||||
// The click may already have landed inside the helper that died; replaying
|
||||
// it would click twice. An observation in the same position is retried.
|
||||
await expect(promise).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
expect(children).toHaveLength(1)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('never replays a mutation once the helper announced it was reading', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const promise = host.request({ tool: 'click', app: 'Notepad', x: 10, y: 10 })
|
||||
await settle()
|
||||
children[0].ready()
|
||||
// Past the announcement the click may already have been synthesized: the
|
||||
// snapshot that follows it is the fault-prone part, so a missing reply
|
||||
// proves nothing about whether the input landed.
|
||||
children[0].exit(1, 'faulting module gdiplus.dll')
|
||||
|
||||
await expect(promise).rejects.toThrow(/runtime host exited/)
|
||||
expect(children).toHaveLength(1)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('replays a mutation only for a helper that died before announcing readiness', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].ready()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await first
|
||||
|
||||
const crashed = host.request({ tool: 'click', app: 'Notepad', x: 1, y: 1 })
|
||||
await settle()
|
||||
children[0].exit(1, 'boom')
|
||||
await expect(crashed).rejects.toThrow(/runtime host exited/)
|
||||
|
||||
const retried = host.request({ tool: 'click', app: 'Notepad', x: 1, y: 1 })
|
||||
await settle()
|
||||
// This helper never announced, so it cannot have read the click: replaying
|
||||
// is a fact rather than a guess, and the caller never sees the stumble.
|
||||
children[1].exit(1, 'Add-Type : Cannot access the temporary directory')
|
||||
await settle()
|
||||
|
||||
expect(children).toHaveLength(3)
|
||||
children[2].ready()
|
||||
children[2].respond({ ok: true, action: { path: 'synthetic' } })
|
||||
await expect(retried).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('does not treat the readiness announcement as an unmatched reply', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].ready()
|
||||
|
||||
expect(children[0].killed).toBe(false)
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await expect(promise).resolves.toEqual({ ok: true, capabilities: {} })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('charges one cooldown per outage, not one per later death', async () => {
|
||||
let clock = 1_000
|
||||
const { host, children } = createHost({ cooldownMs: 60_000, now: () => clock })
|
||||
|
||||
const failed = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
await failEveryStart(children, 'The term is not recognized')
|
||||
await expect(failed).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
|
||||
clock += 61_000
|
||||
const recovered = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children.at(-1)?.respond({ ok: true, capabilities: {} })
|
||||
await recovered
|
||||
|
||||
// One death after recovery must not re-enter a full cooldown; the previous
|
||||
// outage was already paid for.
|
||||
const crashed = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children.at(-1)?.exit(1, 'boom')
|
||||
await expect(crashed).rejects.toBeInstanceOf(Error)
|
||||
|
||||
const next = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children.at(-1)?.respond({ ok: true, capabilities: {} })
|
||||
await expect(next).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('charges one failure when a write fails after the helper was torn down', async () => {
|
||||
const { host, children, warnings } = createHost({ deferWrites: true })
|
||||
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} }, 999)
|
||||
await expect(promise).rejects.toThrow(/did not match the pending request/)
|
||||
|
||||
// stop() destroys stdin, so the queued write calls back with an error. That
|
||||
// is the same operation failing, not a second one, and counting it twice
|
||||
// would drive a 3-strike cooldown at half the intended rate.
|
||||
children[0].failQueuedWrites()
|
||||
|
||||
expect(warnings.filter((line) => /helper stopped/.test(line))).toHaveLength(1)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('never lets a stale write error stop a replacement helper', async () => {
|
||||
const { host, children } = createHost({ deferWrites: true })
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} }, 999)
|
||||
await expect(first).rejects.toBeInstanceOf(Error)
|
||||
|
||||
const second = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(2)
|
||||
|
||||
// The late callback belongs to a channel and a request that are both gone.
|
||||
children[0].failQueuedWrites()
|
||||
|
||||
expect(children[1].killed).toBe(false)
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await expect(second).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('ignores a write error for a request that already finished', async () => {
|
||||
const { host, children } = createHost({ deferWrites: true })
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await first
|
||||
|
||||
const second = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
|
||||
// Backpressure can hold a write callback past its own response. The channel
|
||||
// is alive and was never stopped, so only the request id can tell that this
|
||||
// report is stale — this is what pins the host-side guard on its own.
|
||||
children[0].failQueuedWrite(0)
|
||||
|
||||
expect(children[0].killed).toBe(false)
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await expect(second).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('shuts the helper down when idle and starts a new one on the next operation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { host, children } = createHost({ idleShutdownMs: 60_000 })
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await first
|
||||
await settle()
|
||||
|
||||
expect(children[0].killed).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(60_001)
|
||||
expect(children[0].stdinEnded).toBe(true)
|
||||
expect(children[0].killed).toBe(true)
|
||||
|
||||
const next = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(2)
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await expect(next).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('disposes the helper and rejects the in-flight request', async () => {
|
||||
const { host, children } = createHost()
|
||||
const promise = host.request({ tool: 'click', app: 'Notepad' })
|
||||
await settle()
|
||||
|
||||
host.dispose()
|
||||
|
||||
expect(children[0].stdinEnded).toBe(true)
|
||||
expect(children[0].killed).toBe(true)
|
||||
await expect(promise).rejects.toThrow(/shut down/)
|
||||
})
|
||||
|
||||
it('never respawns for a request queued behind dispose', async () => {
|
||||
const { host, children } = createHost()
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
const queued = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
|
||||
host.dispose()
|
||||
await expect(first).rejects.toBeInstanceOf(Error)
|
||||
await expect(queued).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
await settle()
|
||||
|
||||
expect(children).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('falls back to Bypass once when the execution policy blocks the start', async () => {
|
||||
const { host, children, specs, warnings } = createHost()
|
||||
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].exit(1, POLICY_ERROR)
|
||||
await settle()
|
||||
|
||||
expect(children).toHaveLength(2)
|
||||
expect(specs[1].args).toContain('Bypass')
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await expect(promise).resolves.toMatchObject({ ok: true })
|
||||
expect(warnings.some((line) => /trying Bypass/.test(line))).toBe(true)
|
||||
|
||||
// A helper started under Bypass, so the diagnosis is proven and the fallback
|
||||
// is remembered for the session rather than re-probed per call.
|
||||
const next = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(2)
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await next
|
||||
expect(warnings.some((line) => /returning to RemoteSigned/.test(line))).toBe(false)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('returns to RemoteSigned when Bypass does not start a helper either', async () => {
|
||||
let clock = 1_000
|
||||
const { host, children, specs, warnings } = createHost({ cooldownMs: 60_000, now: () => clock })
|
||||
|
||||
// What AppLocker and WDAC constrained language mode look like: the same
|
||||
// SecurityError category, but the block is at script load, so Bypass cannot
|
||||
// lift it and the escalation was a misdiagnosis.
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
await failEveryStart(children, POLICY_ERROR)
|
||||
await expect(promise).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
|
||||
expect(specs[1].args).toContain('Bypass')
|
||||
expect(warnings.some((line) => /returning to RemoteSigned/.test(line))).toBe(true)
|
||||
// The revert lands inside the outage, not just at its end: every attempt
|
||||
// after the fallback is disproved is back on the preferred policy, so the
|
||||
// misdiagnosis costs one Bypass command line rather than one per attempt.
|
||||
expect(specs).toHaveLength(3)
|
||||
expect(specs[2].args).not.toContain('Bypass')
|
||||
|
||||
// Latching here would put the most heavily weighted MDE token on every
|
||||
// later command line, on exactly the hardened host that is watching.
|
||||
clock += 61_000
|
||||
const recovered = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(specs.at(-1)?.args).not.toContain('Bypass')
|
||||
children.at(-1)?.respond({ ok: true, capabilities: {} })
|
||||
await expect(recovered).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('reports itself unavailable when Bypass is also refused', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
await failEveryStart(children, POLICY_ERROR)
|
||||
|
||||
await expect(promise).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('reports itself unavailable when the helper cannot be spawned at all', async () => {
|
||||
const host = new DesktopScriptRuntimeHost('C:\\orca\\runtime.ps1', {
|
||||
powerShellPath: () => 'C:\\Windows\\System32\\powershell.exe',
|
||||
warn: () => {},
|
||||
spawn: () => {
|
||||
throw new Error('spawn ENOENT')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('retries a transient pre-answer death without the caller ever seeing it', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].exit(1, 'Add-Type : Cannot access the temporary directory')
|
||||
await settle()
|
||||
|
||||
expect(children).toHaveLength(2)
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
|
||||
await expect(promise).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('gives up only after repeated start failures, then serves from the host again after the cooldown', async () => {
|
||||
let clock = 1_000
|
||||
const { host, children, warnings } = createHost({ cooldownMs: 60_000, now: () => clock })
|
||||
|
||||
const failed = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
await failEveryStart(children, 'The term is not recognized')
|
||||
await expect(failed).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
|
||||
const attempts = children.length
|
||||
expect(attempts).toBe(3)
|
||||
|
||||
// Inside the cooldown the host stays out of the way without respawning.
|
||||
clock += 30_000
|
||||
await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
expect(children).toHaveLength(attempts)
|
||||
|
||||
// Past it, the next operation re-probes rather than staying degraded forever.
|
||||
clock += 31_000
|
||||
const recovered = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(attempts + 1)
|
||||
children[attempts].respond({ ok: true, capabilities: {} })
|
||||
await expect(recovered).resolves.toMatchObject({ ok: true })
|
||||
|
||||
expect(warnings.at(-1)).toMatch(/recovered/)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('keeps the helper account of a reply it could not tag', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
const child = children[0]
|
||||
// What an old runtime.ps1 sends when a request will not parse: a real error,
|
||||
// with no id to route it by. The desync is honest, but replacing its message
|
||||
// reports a broken stream and loses the only account of the cause.
|
||||
child.respond({ ok: false, error: 'Invalid object passed in' }, child.pendingId() + 500)
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
/did not match the pending request: Invalid object passed in/
|
||||
)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('does not charge a cooldown for requests the helper rejects as malformed', async () => {
|
||||
const { host, children } = createHost({ cooldownMs: 60_000 })
|
||||
|
||||
// A tagged error is the helper working, not failing. Three of them used to
|
||||
// arrive untagged, and three desync aborts is exactly the cooldown.
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: false, error: 'Invalid object passed in' })
|
||||
await expect(promise).resolves.toMatchObject({ ok: false })
|
||||
await settle()
|
||||
}
|
||||
|
||||
expect(children).toHaveLength(1)
|
||||
const next = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await expect(next).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('fails a request that spends its whole timeout queued behind others', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { host, children } = createHost({ requestTimeoutMs: 1_000 })
|
||||
|
||||
// Two ahead of it, because one puts the turn exactly on the deadline.
|
||||
const first = host.request({ tool: 'get_app_state', app: 'Frozen' })
|
||||
const second = host.request({ tool: 'get_app_state', app: 'Frozen' })
|
||||
const queued = host.request({ tool: 'click', app: 'Notepad' })
|
||||
// Asserted before the clock moves: both reject while the test is still
|
||||
// inside advanceTimersByTimeAsync.
|
||||
const firstFailed = expect(first).rejects.toMatchObject({ code: 'action_timeout' })
|
||||
// Its own deadline, not the one it would inherit by reaching the head.
|
||||
const queuedFailed = expect(queued).rejects.toMatchObject({
|
||||
code: 'action_timeout',
|
||||
message: /waiting for earlier operations/
|
||||
})
|
||||
await settle()
|
||||
expect(children[0].requests()).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_001)
|
||||
await firstFailed
|
||||
await queuedFailed
|
||||
|
||||
// Drain past the abandoned request: it is never handed to a helper, because
|
||||
// a click the caller has been told failed must not still land.
|
||||
children[1].respond({ ok: true, state: {} })
|
||||
await expect(second).resolves.toMatchObject({ ok: true })
|
||||
await settle()
|
||||
expect(children.flatMap((child) => child.requests())).not.toContainEqual(
|
||||
expect.objectContaining({ tool: 'click' })
|
||||
)
|
||||
|
||||
// The request that gave up does not poison the queue behind it.
|
||||
const next = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await expect(next).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('gives a queued request its full timeout once it reaches the helper', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { host, children } = createHost({ requestTimeoutMs: 1_000 })
|
||||
|
||||
const head = host.request({ tool: 'handshake' })
|
||||
const queued = host.request({ tool: 'get_app_state', app: 'Slow' })
|
||||
await settle()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(900)
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await expect(head).resolves.toMatchObject({ ok: true })
|
||||
await settle()
|
||||
|
||||
// Past the point the enqueue deadline would have fired: waiting its turn
|
||||
// must not eat the budget the operation itself is entitled to.
|
||||
await vi.advanceTimersByTimeAsync(900)
|
||||
children[0].respond({ ok: true, state: {} })
|
||||
await expect(queued).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
// Both of these deliberately leave `now` unset: the bug was in the default the
|
||||
// host picks, so a test that injects a clock cannot see it.
|
||||
it('does not stretch the cooldown when the wall clock steps backwards', async () => {
|
||||
const wallClock = vi.spyOn(Date, 'now').mockReturnValue(2_000_000_000_000)
|
||||
const { host, children } = createHost({ cooldownMs: 60_000 })
|
||||
|
||||
const failed = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
await failEveryStart(children, 'The term is not recognized')
|
||||
await expect(failed).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
|
||||
// An NTP correction, a VM snapshot restore, a user changing the clock.
|
||||
wallClock.mockReturnValue(2_000_000_000_000 - 3_600_000)
|
||||
|
||||
const refused = await host.request({ tool: 'handshake' }).then(
|
||||
() => null,
|
||||
(error: Error) => error
|
||||
)
|
||||
expect(refused?.message).toMatch(/retrying the runtime host in/)
|
||||
expect(remainingCooldownMs(refused)).toBeLessThanOrEqual(60_000)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('serves from the persistent helper again after a backwards clock step', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(2_000_000_000_000)
|
||||
const { host, children } = createHost({ cooldownMs: 25 })
|
||||
|
||||
const failed = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
await failEveryStart(children, 'The term is not recognized')
|
||||
await expect(failed).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
const attempts = children.length
|
||||
|
||||
vi.mocked(Date.now).mockReturnValue(2_000_000_000_000 - 3_600_000)
|
||||
// Real elapsed time, because the clock under test is the real monotonic one.
|
||||
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||
|
||||
const recovered = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(attempts + 1)
|
||||
children[attempts].respond({ ok: true, capabilities: {} })
|
||||
await expect(recovered).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,384 @@
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary'
|
||||
import { reportComputerDiagnostic } from './computer-sidecar-diagnostics'
|
||||
import { isReplayableTool } from './desktop-script-action'
|
||||
import type { BridgeRequest, BridgeResponse } from './desktop-script-provider-types'
|
||||
import { DesktopScriptRequestQueue } from './desktop-script-request-queue'
|
||||
import {
|
||||
startServeChannel,
|
||||
type DesktopScriptServeChannel,
|
||||
type RuntimeProcessSpawn
|
||||
} from './desktop-script-serve-channel'
|
||||
import {
|
||||
MAX_START_ATTEMPTS,
|
||||
RuntimeHostAvailability,
|
||||
START_FAILURE_COOLDOWN_MS
|
||||
} from './desktop-script-runtime-availability'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
import {
|
||||
isExecutionPolicyBlocked,
|
||||
windowsPowerShellRuntimeArgs
|
||||
} from './windows-powershell-execution-policy'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const IDLE_SHUTDOWN_MS = 120_000
|
||||
|
||||
/** Code the client keys on to serve this one operation from the one-shot bridge. */
|
||||
export const RUNTIME_HOST_UNAVAILABLE = 'runtime_host_unavailable'
|
||||
|
||||
export type DesktopScriptRuntimeHostOptions = {
|
||||
spawn?: RuntimeProcessSpawn
|
||||
powerShellPath?: () => string
|
||||
requestTimeoutMs?: number
|
||||
idleShutdownMs?: number
|
||||
cooldownMs?: number
|
||||
now?: () => number
|
||||
warn?: (message: string) => void
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
id: number
|
||||
resolve: (response: BridgeResponse) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
export function isRuntimeHostUnavailable(error: unknown): boolean {
|
||||
return error instanceof RuntimeClientError && error.code === RUNTIME_HOST_UNAVAILABLE
|
||||
}
|
||||
|
||||
/**
|
||||
* One long-lived `runtime.ps1 -Serve` process serving every computer-use
|
||||
* operation over NDJSON on stdin/stdout.
|
||||
*
|
||||
* Why persistent: the one-shot bridge started a powershell.exe per click, and
|
||||
* each one re-emitted the script's inline `Add-Type` P/Invoke assembly, which
|
||||
* Defender for Endpoint reports as suspicious MSIL emission alongside the
|
||||
* screen capture. Compiling once per session collapses a burst of short-lived
|
||||
* PIDs into a single process.
|
||||
*
|
||||
* Requests are strictly serialized, and each carries an id the helper echoes.
|
||||
* Serialization alone would leave a single stray line answering every later
|
||||
* request with the previous response — silently acting on stale element
|
||||
* indexes, with no error raised — so the id is checked and a mismatch is fatal
|
||||
* to the child rather than merely logged.
|
||||
*/
|
||||
export class DesktopScriptRuntimeHost {
|
||||
private channel: DesktopScriptServeChannel | null = null
|
||||
private pending: PendingRequest | null = null
|
||||
private idleTimer: NodeJS.Timeout | null = null
|
||||
private childReady = false
|
||||
private childAnswered = false
|
||||
/**
|
||||
* Set once any helper has announced itself, which proves the script on disk
|
||||
* speaks the ready protocol. Until then a mutating request is not replayed
|
||||
* even on a clean start failure, because ORCA_COMPUTER_DESKTOP_SCRIPT_PROVIDER_PATH
|
||||
* can point at an older runtime.ps1 that simply never announces.
|
||||
*/
|
||||
private readyProtocolConfirmed = false
|
||||
private disposed = false
|
||||
private nextRequestId = 1
|
||||
private readonly availability: RuntimeHostAvailability
|
||||
private readonly queue: DesktopScriptRequestQueue
|
||||
private readonly requestTimeoutMs: number
|
||||
private readonly idleShutdownMs: number
|
||||
|
||||
constructor(
|
||||
private readonly scriptPath: string,
|
||||
private readonly options: DesktopScriptRuntimeHostOptions = {}
|
||||
) {
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS
|
||||
this.idleShutdownMs = options.idleShutdownMs ?? IDLE_SHUTDOWN_MS
|
||||
this.queue = new DesktopScriptRequestQueue(this.requestTimeoutMs, () => this.armIdleTimer())
|
||||
this.availability = new RuntimeHostAvailability(
|
||||
options.cooldownMs ?? START_FAILURE_COOLDOWN_MS,
|
||||
(message) => (options.warn ?? reportComputerDiagnostic)(message),
|
||||
options.now
|
||||
)
|
||||
}
|
||||
|
||||
request(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
return this.queue.enqueue(() => this.send(request))
|
||||
}
|
||||
|
||||
/** Permanently stop this host. Callers build a new one for a new session. */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.clearIdleTimer()
|
||||
this.availability.clearCooldown()
|
||||
this.stopChannel()
|
||||
this.rejectPending(
|
||||
new RuntimeClientError('accessibility_error', 'desktop provider runtime host was shut down')
|
||||
)
|
||||
}
|
||||
|
||||
private async send(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
this.clearIdleTimer()
|
||||
// Why checked here and not only on entry: requests queue, and dispose can
|
||||
// land while one waits its turn. Without this a teardown respawns a helper.
|
||||
if (this.disposed) {
|
||||
throw this.unavailableError('runtime host was disposed')
|
||||
}
|
||||
const cooldown = this.availability.remainingCooldown()
|
||||
if (cooldown > 0) {
|
||||
throw this.unavailableError(`retrying the runtime host in ${cooldown}ms`)
|
||||
}
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= MAX_START_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const response = await this.sendOnce(request)
|
||||
this.availability.recordSuccess()
|
||||
return response
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (this.availability.policyRetryPending) {
|
||||
this.availability.escalateExecutionPolicy()
|
||||
continue
|
||||
}
|
||||
// Only this error proves no helper started, which is what disproves the
|
||||
// escalation; a helper that started and then died proves the opposite.
|
||||
if (isRuntimeHostUnavailable(error)) {
|
||||
this.availability.abandonUnprovenFallback()
|
||||
}
|
||||
// A helper that answered and then died is a crash, not a bad start: the
|
||||
// caller sees it and the next operation gets a fresh process — unless it
|
||||
// keeps happening, which is thrash the one-shot bridge should absorb.
|
||||
if (!isRuntimeHostUnavailable(error) || !this.mayReplay(request)) {
|
||||
if (this.availability.exhausted) {
|
||||
this.availability.enterCooldown()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.availability.warn(
|
||||
`runtime host failed to start (attempt ${attempt}/${MAX_START_ATTEMPTS}): ${errorText(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
this.availability.enterCooldown()
|
||||
throw lastError
|
||||
}
|
||||
|
||||
private sendOnce(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
let channel: DesktopScriptServeChannel
|
||||
try {
|
||||
channel = this.ensureChannel()
|
||||
} catch (error) {
|
||||
this.availability.recordFailure()
|
||||
return Promise.reject(this.unavailableError(errorText(error)))
|
||||
}
|
||||
const id = this.nextRequestId++
|
||||
return new Promise((resolve, reject) => {
|
||||
// Why kill rather than wait: a hung UI Automation call cannot be
|
||||
// cancelled, so the process itself is the only thing left to reclaim.
|
||||
const timer = setTimeout(() => {
|
||||
this.abortChannel(
|
||||
new RuntimeClientError(
|
||||
'action_timeout',
|
||||
`desktop provider timed out after ${this.requestTimeoutMs}ms`
|
||||
)
|
||||
)
|
||||
}, this.requestTimeoutMs)
|
||||
timer.unref?.()
|
||||
this.pending = { id, resolve, reject, timer }
|
||||
channel.write(`${JSON.stringify({ ...request, requestId: id })}\n`, (error) => {
|
||||
// Bind the report to what it was written for: a late callback must not
|
||||
// charge a second failure for this operation, nor stop a replacement
|
||||
// helper and reject a later request with this one's error. Deliberately
|
||||
// redundant with the channel's own closed guard — keep both. This one
|
||||
// also covers a live channel whose request has already been answered,
|
||||
// which the channel cannot see; that case is what pins it.
|
||||
//
|
||||
// Redundant does not mean untested: removing either guard alone fails a
|
||||
// test, so neither can be deleted as "the one the other covers".
|
||||
if (this.channel !== channel || this.pending?.id !== id) {
|
||||
return
|
||||
}
|
||||
this.abortChannel(new RuntimeClientError('accessibility_error', error.message))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private ensureChannel(): DesktopScriptServeChannel {
|
||||
if (this.channel) {
|
||||
return this.channel
|
||||
}
|
||||
this.childReady = false
|
||||
this.childAnswered = false
|
||||
const channel: DesktopScriptServeChannel = startServeChannel(
|
||||
{
|
||||
program: (this.options.powerShellPath ?? windowsPowerShellPath)(),
|
||||
args: windowsPowerShellRuntimeArgs(this.scriptPath, this.availability.executionPolicy, [
|
||||
'-Serve'
|
||||
]),
|
||||
env: process.env
|
||||
},
|
||||
this.options.spawn ?? spawnProcess,
|
||||
{
|
||||
onLine: (line) => this.deliver(line),
|
||||
// A replaced channel can still report; that must not fail the live one.
|
||||
onGone: (detail) => {
|
||||
if (this.channel === channel) {
|
||||
this.handleGone(detail)
|
||||
}
|
||||
},
|
||||
onOverflow: () =>
|
||||
this.abortChannel(
|
||||
new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'desktop provider response exceeded the runtime host buffer'
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
this.channel = channel
|
||||
return channel
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the helper that just died can be proved not to have run the request.
|
||||
*
|
||||
* Why proof and not inference: "no reply came back" is not "nothing happened".
|
||||
* runtime.ps1 synthesizes the input and only then builds the snapshot, which
|
||||
* allocates a full-window bitmap and walks the UIA tree — a native fault there
|
||||
* is uncatchable and would leave a click already delivered. Retrying on that
|
||||
* inference turns one requested click into four.
|
||||
*/
|
||||
private mayReplay(request: BridgeRequest): boolean {
|
||||
if (this.childReady || this.childAnswered) {
|
||||
return false
|
||||
}
|
||||
return this.readyProtocolConfirmed || isReplayableTool(request.tool)
|
||||
}
|
||||
|
||||
private deliver(line: string): void {
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>
|
||||
} catch {
|
||||
// Not a response at all — a PowerShell banner, a stray write. Dropping it
|
||||
// is safe now that the id below is what decides which request is answered,
|
||||
// and it keeps a chatty console from making the helper unusable.
|
||||
return
|
||||
}
|
||||
// The readiness announcement carries no request id and answers nothing.
|
||||
if (parsed.ready === true && parsed.requestId === undefined) {
|
||||
this.childReady = true
|
||||
this.readyProtocolConfirmed = true
|
||||
this.availability.confirmExecutionPolicy()
|
||||
return
|
||||
}
|
||||
const pending = this.pending
|
||||
if (!pending || parsed.requestId !== pending.id) {
|
||||
// One unmatched reply would otherwise shift every later response by one.
|
||||
// Carry the helper's own message when it sent one: a line it could not tag
|
||||
// with an id is usually the only account of what went wrong, and reporting
|
||||
// a bare desync in its place loses the cause for good.
|
||||
const reported = typeof parsed.error === 'string' ? `: ${parsed.error}` : ''
|
||||
this.abortChannel(
|
||||
new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
`desktop provider response did not match the pending request${reported}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
// Only a reply this host can prove is its own counts as the helper working.
|
||||
this.childAnswered = true
|
||||
this.pending = null
|
||||
clearTimeout(pending.timer)
|
||||
const { requestId: _echoed, ...response } = parsed
|
||||
pending.resolve(response as BridgeResponse)
|
||||
}
|
||||
|
||||
private handleGone(detail: string): void {
|
||||
const started = this.childReady || this.childAnswered
|
||||
this.channel = null
|
||||
this.availability.recordFailure()
|
||||
if (!started && this.availability.atPreferredPolicy && isExecutionPolicyBlocked(detail)) {
|
||||
this.availability.requestPolicyRetry()
|
||||
// Unavailable rather than a generic error, because this can now be the
|
||||
// final attempt: reverting an unproven escalation puts the host back on
|
||||
// the preferred policy, so a later attempt can land here again. Only this
|
||||
// code routes the operation to the one-shot bridge, which carries its own
|
||||
// policy fallback; anything else fails the operation outright.
|
||||
this.rejectPending(this.unavailableError(detail))
|
||||
return
|
||||
}
|
||||
if (!started) {
|
||||
this.rejectPending(this.unavailableError(detail))
|
||||
return
|
||||
}
|
||||
this.rejectPending(
|
||||
new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
`desktop provider runtime host exited: ${detail}`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a helper this host has judged unusable — a timeout, a desynchronised
|
||||
* reply, an oversized line.
|
||||
*
|
||||
* Why it counts as a failure: stopping the channel suppresses the exit
|
||||
* handler, so without this these paths bypassed the accounting entirely and a
|
||||
* helper that failed this way on every operation was respawned once per
|
||||
* operation forever — the burst this host exists to remove, restored through
|
||||
* its own recovery path.
|
||||
*/
|
||||
private abortChannel(error: Error): void {
|
||||
this.stopChannel()
|
||||
this.availability.recordFailure()
|
||||
this.availability.warn(`runtime host helper stopped: ${error.message}`)
|
||||
this.rejectPending(error)
|
||||
}
|
||||
|
||||
private stopChannel(): void {
|
||||
const channel = this.channel
|
||||
this.channel = null
|
||||
channel?.stop()
|
||||
}
|
||||
|
||||
private takePending(): PendingRequest | null {
|
||||
const pending = this.pending
|
||||
this.pending = null
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
private rejectPending(error: Error): void {
|
||||
this.takePending()?.reject(error)
|
||||
}
|
||||
|
||||
private armIdleTimer(): void {
|
||||
this.clearIdleTimer()
|
||||
if (!this.channel) {
|
||||
return
|
||||
}
|
||||
this.idleTimer = setTimeout(() => {
|
||||
this.idleTimer = null
|
||||
this.stopChannel()
|
||||
}, this.idleShutdownMs)
|
||||
this.idleTimer.unref?.()
|
||||
}
|
||||
|
||||
private clearIdleTimer(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer)
|
||||
this.idleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private unavailableError(message: string): RuntimeClientError {
|
||||
return new RuntimeClientError(
|
||||
RUNTIME_HOST_UNAVAILABLE,
|
||||
`desktop provider runtime host could not start: ${message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary'
|
||||
import { DesktopScriptRuntimeHost } from './desktop-script-runtime-host'
|
||||
import { startServeChannel } from './desktop-script-serve-channel'
|
||||
import {
|
||||
PREFERRED_WINDOWS_EXECUTION_POLICY,
|
||||
windowsPowerShellRuntimeArgs
|
||||
} from './windows-powershell-execution-policy'
|
||||
|
||||
/**
|
||||
* The other half of the serve-mode proof: the unit test drives a fake child,
|
||||
* this one drives the real `runtime.ps1 -Serve` on a real Windows box.
|
||||
*
|
||||
* Both are needed. The framing that matters — one NDJSON line per response,
|
||||
* megabyte-scale screenshot payloads, a console writer that actually flushes —
|
||||
* only exists in PowerShell, and a fake child cannot disprove any of it.
|
||||
*
|
||||
* Runs only on win32; skipped elsewhere.
|
||||
*/
|
||||
const describeOnWindows = process.platform === 'win32' ? describe : describe.skip
|
||||
|
||||
const SCRIPT_PATH = resolve(__dirname, '../../../native/computer-use-windows/runtime.ps1')
|
||||
|
||||
describeOnWindows('runtime.ps1 serve mode', () => {
|
||||
let host: DesktopScriptRuntimeHost | null = null
|
||||
let spawns = 0
|
||||
|
||||
function startHost(): DesktopScriptRuntimeHost {
|
||||
spawns = 0
|
||||
host = new DesktopScriptRuntimeHost(SCRIPT_PATH, {
|
||||
warn: () => {},
|
||||
spawn: (spec) => {
|
||||
spawns++
|
||||
return spawnProcess(spec)
|
||||
}
|
||||
})
|
||||
return host
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
host?.dispose()
|
||||
host = null
|
||||
})
|
||||
|
||||
it('answers repeated operations from a single PowerShell process', async () => {
|
||||
const runtime = startHost()
|
||||
|
||||
await expect(runtime.request({ tool: 'handshake' })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
capabilities: { protocolVersion: 1, provider: 'orca-computer-use-windows' }
|
||||
})
|
||||
|
||||
const apps = await runtime.request({ tool: 'list_apps' })
|
||||
expect(apps.ok).toBe(true)
|
||||
expect(Array.isArray(apps.apps)).toBe(true)
|
||||
|
||||
await expect(runtime.request({ tool: 'handshake' })).resolves.toMatchObject({ ok: true })
|
||||
|
||||
expect(spawns).toBe(1)
|
||||
})
|
||||
|
||||
it('returns a structured error for a bad request without killing the helper', async () => {
|
||||
const runtime = startHost()
|
||||
|
||||
await expect(runtime.request({ tool: 'not_a_tool' })).resolves.toMatchObject({ ok: false })
|
||||
await expect(runtime.request({ tool: 'handshake' })).resolves.toMatchObject({ ok: true })
|
||||
expect(spawns).toBe(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* The host can only write well-formed JSON, so the parse-failure branch of the
|
||||
* serve loop is unreachable through it. Driving the channel directly is the
|
||||
* only way to prove what the real PowerShell answers.
|
||||
*/
|
||||
it('echoes the id it can recover when a request will not parse', async () => {
|
||||
const answer = await answerRawLine('{"tool":"handshake","requestId":7')
|
||||
|
||||
// Tagged, so the host resolves the waiting request with a failed operation
|
||||
// instead of reading an untagged line as a desynchronised stream.
|
||||
expect(answer).toMatchObject({ ok: false, requestId: 7 })
|
||||
expect(String(answer.error)).not.toBe('')
|
||||
})
|
||||
|
||||
it('reports an error for a line with no recoverable id', async () => {
|
||||
const answer = await answerRawLine('{"tool":"handshake"')
|
||||
|
||||
expect(answer).toMatchObject({ ok: false })
|
||||
expect(answer.requestId).toBeUndefined()
|
||||
expect(String(answer.error)).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
/** One raw line into a real `runtime.ps1 -Serve`, and the line it writes back. */
|
||||
function answerRawLine(raw: string): Promise<Record<string, unknown>> {
|
||||
return new Promise((settle, fail) => {
|
||||
const channel = startServeChannel(
|
||||
{
|
||||
program: windowsPowerShellPath(),
|
||||
args: windowsPowerShellRuntimeArgs(SCRIPT_PATH, PREFERRED_WINDOWS_EXECUTION_POLICY, [
|
||||
'-Serve'
|
||||
]),
|
||||
env: process.env
|
||||
},
|
||||
spawnProcess,
|
||||
{
|
||||
onLine: (line) => {
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (parsed.ready === true) {
|
||||
channel.write(`${raw}\n`, fail)
|
||||
return
|
||||
}
|
||||
channel.stop()
|
||||
settle(parsed)
|
||||
},
|
||||
onGone: (detail) => fail(new Error(`helper exited before answering: ${detail}`)),
|
||||
onOverflow: () => {
|
||||
channel.stop()
|
||||
fail(new Error('helper overflowed the response buffer'))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DesktopScriptServeChannel, type RuntimeChildProcess } from './desktop-script-serve-channel'
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
readonly stdout = new EventEmitter()
|
||||
readonly stderr = new EventEmitter()
|
||||
readonly writes: string[] = []
|
||||
killed = false
|
||||
private readonly pendingWrites: ((error?: Error | null) => void)[] = []
|
||||
|
||||
readonly stdin = {
|
||||
write: (chunk: string, callback?: (error?: Error | null) => void): boolean => {
|
||||
this.writes.push(chunk)
|
||||
if (callback) {
|
||||
this.pendingWrites.push(callback)
|
||||
}
|
||||
return true
|
||||
},
|
||||
end: (): void => {},
|
||||
on: (): void => {}
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
this.killed = true
|
||||
return true
|
||||
}
|
||||
|
||||
/** What a destroyed stdin does to writes still queued at teardown. */
|
||||
failQueuedWrites(): void {
|
||||
for (const callback of this.pendingWrites.splice(0)) {
|
||||
callback(new Error('ERR_STREAM_DESTROYED'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createChannel() {
|
||||
const child = new FakeChild()
|
||||
const handlers = { onLine: vi.fn(), onGone: vi.fn(), onOverflow: vi.fn() }
|
||||
const channel = new DesktopScriptServeChannel(child as unknown as RuntimeChildProcess, handlers)
|
||||
return { channel, child, handlers }
|
||||
}
|
||||
|
||||
describe('DesktopScriptServeChannel', () => {
|
||||
it('splits responses into lines and tolerates a trailing carriage return', () => {
|
||||
const { child, handlers } = createChannel()
|
||||
|
||||
child.stdout.emit('data', Buffer.from('{"a":1}\r\n{"b":2}\n', 'utf8'))
|
||||
|
||||
expect(handlers.onLine.mock.calls.map(([line]) => line)).toEqual(['{"a":1}', '{"b":2}'])
|
||||
})
|
||||
|
||||
it('reports the exit reason with the stderr tail', () => {
|
||||
const { child, handlers } = createChannel()
|
||||
|
||||
child.stderr.emit('data', Buffer.from('it broke', 'utf8'))
|
||||
child.emit('close', 1, null)
|
||||
|
||||
expect(handlers.onGone).toHaveBeenCalledWith('code 1: it broke')
|
||||
})
|
||||
|
||||
describe('once stopped', () => {
|
||||
/**
|
||||
* The channel's half of the stale-callback guard, pinned here rather than
|
||||
* through the host: the host refuses a stale report too, so a host-level
|
||||
* test passes with either guard alone and neither ends up covered.
|
||||
*/
|
||||
it('accepts no further writes', () => {
|
||||
const { channel, child } = createChannel()
|
||||
|
||||
channel.stop()
|
||||
channel.write('{"tool":"click"}\n', vi.fn())
|
||||
|
||||
expect(child.writes).toEqual([])
|
||||
})
|
||||
|
||||
it('reports no error from a write that was already queued', () => {
|
||||
const { channel, child } = createChannel()
|
||||
const onError = vi.fn()
|
||||
|
||||
channel.write('{"tool":"click"}\n', onError)
|
||||
channel.stop()
|
||||
child.failQueuedWrites()
|
||||
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports neither lines nor the exit it was asked to cause', () => {
|
||||
const { channel, child, handlers } = createChannel()
|
||||
|
||||
channel.stop()
|
||||
child.stdout.emit('data', Buffer.from('{"a":1}\n', 'utf8'))
|
||||
child.emit('close', 0, null)
|
||||
|
||||
expect(handlers.onLine).not.toHaveBeenCalled()
|
||||
expect(handlers.onGone).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import type { ProcessSpec } from '../../shared/child-process/process-spec'
|
||||
import type { spawnProcess } from '../../shared/child-process/run-process'
|
||||
|
||||
/** The all-pipes child `spawnProcess` returns; avoids a node:child_process import. */
|
||||
export type RuntimeChildProcess = ReturnType<typeof spawnProcess>
|
||||
|
||||
export type RuntimeProcessSpawn = (spec: ProcessSpec) => RuntimeChildProcess
|
||||
|
||||
/** UTF-16 units, not bytes — this bounds the buffer, it is not a payload contract. */
|
||||
const MAX_RESPONSE_CHARS = 20 * 1024 * 1024
|
||||
const MAX_STDERR_CHARS = 4096
|
||||
|
||||
export type ServeChannelHandlers = {
|
||||
/** One complete line from the helper, without its terminator. */
|
||||
onLine: (line: string) => void
|
||||
/** The helper is gone; detail carries the exit reason and its stderr tail. */
|
||||
onGone: (detail: string) => void
|
||||
/** The helper produced more than one buffer's worth without a line break. */
|
||||
onOverflow: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One `runtime.ps1 -Serve` child, framed as NDJSON lines.
|
||||
*
|
||||
* Split from the host so the host reads as what it is — a queue, a retry policy
|
||||
* and a correlation check — rather than that plus stream plumbing. Responses
|
||||
* carry base64 screenshots and routinely exceed a megabyte, so lines are
|
||||
* reassembled across chunks with a decoder that survives a code point split
|
||||
* across a chunk boundary.
|
||||
*/
|
||||
export class DesktopScriptServeChannel {
|
||||
private readonly decoder = new StringDecoder('utf8')
|
||||
private buffer = ''
|
||||
private stderrTail = ''
|
||||
private detach: (() => void) | null = null
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
private readonly child: RuntimeChildProcess,
|
||||
private readonly handlers: ServeChannelHandlers
|
||||
) {
|
||||
const onStdout = (chunk: Buffer | string): void => this.readStdout(chunk)
|
||||
const onStderr = (chunk: Buffer | string): void => {
|
||||
this.stderrTail = `${this.stderrTail}${chunk.toString()}`.slice(-MAX_STDERR_CHARS)
|
||||
}
|
||||
// Why close and not exit: the caller classifies the failure from stderr, and
|
||||
// only close guarantees the stdio streams were drained first.
|
||||
const onClose = (code: number | null, signal: NodeJS.Signals | null): void =>
|
||||
this.reportGone(signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`)
|
||||
const onError = (error: Error): void => this.reportGone(error.message)
|
||||
child.stdout.on('data', onStdout)
|
||||
child.stderr.on('data', onStderr)
|
||||
child.once('close', onClose)
|
||||
child.once('error', onError)
|
||||
// An unhandled stream error is an uncaught exception in the main process.
|
||||
child.stdin.on('error', () => {})
|
||||
this.detach = (): void => {
|
||||
child.stdout.off('data', onStdout)
|
||||
child.stderr.off('data', onStderr)
|
||||
child.off('close', onClose)
|
||||
child.off('error', onError)
|
||||
child.on('error', () => {})
|
||||
}
|
||||
}
|
||||
|
||||
write(payload: string, onError: (error: Error) => void): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
this.child.stdin.write(payload, (error) => {
|
||||
// A destroyed stdin calls back after stop(); reporting then charges the
|
||||
// caller a second failure for one operation. Deliberately redundant with
|
||||
// the host's own staleness check — keep both, and note that each is
|
||||
// pinned separately, this one by the "once stopped" tests here.
|
||||
if (error && !this.closed) {
|
||||
onError(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Stop the helper and go silent; handlers are not called afterwards. */
|
||||
stop(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
this.detach?.()
|
||||
this.detach = null
|
||||
this.buffer = ''
|
||||
// Closing stdin ends the serve loop; the kill covers a wedged helper.
|
||||
try {
|
||||
this.child.stdin.end()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
this.child.kill()
|
||||
}
|
||||
|
||||
private reportGone(detail: string): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
const text = [detail, this.stderrTail.trim()].filter(Boolean).join(': ')
|
||||
this.closed = true
|
||||
this.detach?.()
|
||||
this.detach = null
|
||||
this.handlers.onGone(text)
|
||||
}
|
||||
|
||||
private readStdout(chunk: Buffer | string): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk)
|
||||
if (this.buffer.length > MAX_RESPONSE_CHARS) {
|
||||
this.buffer = ''
|
||||
this.handlers.onOverflow()
|
||||
return
|
||||
}
|
||||
for (let newline = this.buffer.indexOf('\n'); newline >= 0;) {
|
||||
// Slice a trailing CR off by index; trimming copies the whole payload.
|
||||
const end = newline > 0 && this.buffer.charCodeAt(newline - 1) === 13 ? newline - 1 : newline
|
||||
const line = this.buffer.slice(0, end)
|
||||
this.buffer = this.buffer.slice(newline + 1)
|
||||
if (line.length > 0) {
|
||||
this.handlers.onLine(line)
|
||||
// A handler may have stopped this channel; stop reading its backlog.
|
||||
if (this.closed) {
|
||||
this.buffer = ''
|
||||
return
|
||||
}
|
||||
}
|
||||
newline = this.buffer.indexOf('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startServeChannel(
|
||||
spec: ProcessSpec,
|
||||
spawn: RuntimeProcessSpawn,
|
||||
handlers: ServeChannelHandlers
|
||||
): DesktopScriptServeChannel {
|
||||
return new DesktopScriptServeChannel(spawn(spec), handlers)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ComputerSnapshotResult
|
||||
} from '../../shared/runtime-types'
|
||||
import { normalizeComputerActionResult } from './computer-action-verification-normalization'
|
||||
import { isComputerSidecarDiagnostic, logComputerDiagnostic } from './computer-sidecar-diagnostics'
|
||||
import { validateComputerSidecarPasteText } from './computer-sidecar-paste-validation'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
@@ -245,6 +246,11 @@ class ComputerSidecarProcess {
|
||||
}
|
||||
|
||||
private handleMessage(message: unknown): void {
|
||||
// The sidecar's stdio is piped and unread, so its warnings arrive here.
|
||||
if (isComputerSidecarDiagnostic(message)) {
|
||||
logComputerDiagnostic(message.message)
|
||||
return
|
||||
}
|
||||
if (!isSidecarResponse(message)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ type SidecarRequest = {
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Why disconnect carries the weight on Windows: the parent stops the sidecar
|
||||
// with kill('SIGTERM'), which is TerminateProcess there, so the SIGTERM handler
|
||||
// below never runs and teardown rides on the IPC channel closing instead. A
|
||||
// helper wedged inside a UI Automation call can still outlive that and deliver
|
||||
// input after teardown; only a real signal would preempt it.
|
||||
process.once('disconnect', shutdownProviders)
|
||||
process.once('SIGTERM', () => {
|
||||
shutdownProviders()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
FALLBACK_WINDOWS_EXECUTION_POLICY,
|
||||
PREFERRED_WINDOWS_EXECUTION_POLICY,
|
||||
isExecutionPolicyBlocked,
|
||||
windowsPowerShellRuntimeArgs
|
||||
} from './windows-powershell-execution-policy'
|
||||
|
||||
/**
|
||||
* Captured from powershell.exe on Windows, verbatim including the hard wrapping.
|
||||
*
|
||||
* The discriminator has to be pinned in both directions: a policy block must
|
||||
* escalate once, and a plain access denial must not, because escalation is
|
||||
* sticky for the session and lands on `-ExecutionPolicy Bypass`.
|
||||
*/
|
||||
const POLICY_BLOCKED_RESTRICTED = [
|
||||
'File C:\\Temp\\runtime.ps1 cannot be loaded because running scripts is disabled on this system. For more ',
|
||||
'information, see about_Execution_Policies at https:/go.microsoft.com/fwlink/?LinkID=135170.',
|
||||
' + CategoryInfo : SecurityError: (:) [], ParentContainsErrorRecordException',
|
||||
' + FullyQualifiedErrorId : UnauthorizedAccess'
|
||||
].join('\r\n')
|
||||
|
||||
const POLICY_BLOCKED_REMOTE_SIGNED = [
|
||||
'File C:\\Temp\\runtime.ps1 cannot be loaded. The file ',
|
||||
'C:\\Temp\\runtime.ps1 is not digitally signed. You cannot run this script on the current system. For more ',
|
||||
'information about running scripts and setting execution policy, see about_Execution_Policies at https:/go.microsoft.com/fwlink/?LinkID=135170.',
|
||||
' + CategoryInfo : SecurityError: (:) [], ParentContainsErrorRecordException',
|
||||
' + FullyQualifiedErrorId : UnauthorizedAccess'
|
||||
].join('\r\n')
|
||||
|
||||
/** No execution policy involved: .NET refusing a file the process may not read. */
|
||||
const GENUINE_ACCESS_DENIED = [
|
||||
'Exception calling "ReadAllText" with "1" argument(s): "Access to the path \'C:\\Windows\\System32\\config\\SAM\' is denied."',
|
||||
'At C:\\Temp\\runtime.ps1:1 char:1',
|
||||
'+ [System.IO.File]::ReadAllText("C:\\Windows\\System32\\config\\SAM")',
|
||||
'+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
|
||||
' + CategoryInfo : NotSpecified: (:) [], MethodInvocationException',
|
||||
' + FullyQualifiedErrorId : UnauthorizedAccessException'
|
||||
].join('\r\n')
|
||||
|
||||
describe('isExecutionPolicyBlocked', () => {
|
||||
it('recognises a policy block under either policy', () => {
|
||||
expect(isExecutionPolicyBlocked(POLICY_BLOCKED_RESTRICTED)).toBe(true)
|
||||
expect(isExecutionPolicyBlocked(POLICY_BLOCKED_REMOTE_SIGNED)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not read a plain access denial as a policy block', () => {
|
||||
// UnauthorizedAccessException merely starts with the policy error id. Without
|
||||
// the word boundary this matched, and one locked file downgraded the whole
|
||||
// session to Bypass with no path back.
|
||||
expect(isExecutionPolicyBlocked(GENUINE_ACCESS_DENIED)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps recognising a block when the record labels are localized', () => {
|
||||
// The labels are translated on a non-English host; the ids and the help
|
||||
// topic are not, so the match must not depend on the labels.
|
||||
const localized = POLICY_BLOCKED_RESTRICTED.replace('CategoryInfo', 'Categoria')
|
||||
.replace('FullyQualifiedErrorId', 'IdErroreCompleto')
|
||||
.replace(
|
||||
'cannot be loaded because running scripts is disabled on this system',
|
||||
'non puo essere caricato'
|
||||
)
|
||||
expect(isExecutionPolicyBlocked(localized)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores the failures the helper reports every day', () => {
|
||||
expect(isExecutionPolicyBlocked('code 1: The term is not recognized')).toBe(false)
|
||||
expect(isExecutionPolicyBlocked('Add-Type : Cannot access the temporary directory')).toBe(false)
|
||||
expect(isExecutionPolicyBlocked('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('windowsPowerShellRuntimeArgs', () => {
|
||||
it('never emits Bypass unless the caller escalated to it', () => {
|
||||
const preferred = windowsPowerShellRuntimeArgs(
|
||||
'C:\\orca\\runtime.ps1',
|
||||
PREFERRED_WINDOWS_EXECUTION_POLICY,
|
||||
['-Serve']
|
||||
)
|
||||
expect(preferred).not.toContain(FALLBACK_WINDOWS_EXECUTION_POLICY)
|
||||
expect(preferred).toEqual([
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'RemoteSigned',
|
||||
'-File',
|
||||
'C:\\orca\\runtime.ps1',
|
||||
'-Serve'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Execution-policy handling for the Windows computer-use runtime script.
|
||||
*
|
||||
* Why not `Bypass` outright: it is the highest-weighted token on a
|
||||
* powershell.exe command line for Defender for Endpoint, and the shipped
|
||||
* runtime.ps1 does not need it — NSIS extraction writes no Zone.Identifier, so
|
||||
* an unsigned local script runs under `RemoteSigned`. `Restricted` is still the
|
||||
* Windows client default though, so a policy-blocked start must fall back once
|
||||
* rather than leaving computer use broken.
|
||||
*/
|
||||
export type WindowsExecutionPolicy = 'RemoteSigned' | 'Bypass'
|
||||
|
||||
export const PREFERRED_WINDOWS_EXECUTION_POLICY: WindowsExecutionPolicy = 'RemoteSigned'
|
||||
export const FALLBACK_WINDOWS_EXECUTION_POLICY: WindowsExecutionPolicy = 'Bypass'
|
||||
|
||||
/**
|
||||
* Matches the SecurityError PowerShell emits for `-File` under a blocking policy.
|
||||
*
|
||||
* Every alternative is a PowerShell or .NET identifier, never prose. The prose
|
||||
* differs by policy ("running scripts is disabled" under Restricted, "is not
|
||||
* digitally signed" under RemoteSigned), is localized, and PowerShell hard-wraps
|
||||
* it mid-sentence at the console width, so it can anchor nothing.
|
||||
*
|
||||
* The `\b` after UnauthorizedAccess is the whole discriminator and must not be
|
||||
* dropped. `UnauthorizedAccess` is the FullyQualifiedErrorId of a policy block,
|
||||
* but it is also a strict prefix of `UnauthorizedAccessException`, which .NET
|
||||
* raises for an ordinary locked or ACL-denied file: an AV scan holding
|
||||
* runtime.ps1, a locked CSC temp directory, a roaming-profile hiccup. Matching
|
||||
* that escalates to `Bypass` for the rest of the session — the exact command
|
||||
* line token this stack exists to stop emitting — and on the one-shot path
|
||||
* replays an operation that already ran.
|
||||
*
|
||||
* Anchoring on the `FullyQualifiedErrorId:`/`CategoryInfo:` labels would be more
|
||||
* precise still, but the labels are localized where these values are not, so a
|
||||
* non-English host would stop recognising a real block and lose the fallback.
|
||||
*/
|
||||
const EXECUTION_POLICY_BLOCKED = /\bUnauthorizedAccess\b|\bSecurityError\b|about_Execution_Policies/
|
||||
|
||||
export function isExecutionPolicyBlocked(text: string): boolean {
|
||||
return EXECUTION_POLICY_BLOCKED.test(text)
|
||||
}
|
||||
|
||||
export function windowsPowerShellRuntimeArgs(
|
||||
scriptPath: string,
|
||||
policy: WindowsExecutionPolicy,
|
||||
scriptArgs: readonly string[] = []
|
||||
): string[] {
|
||||
return [
|
||||
// -NoLogo: a banner on stdout would be read as a malformed response line.
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
policy,
|
||||
'-File',
|
||||
scriptPath,
|
||||
...scriptArgs
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user