Files
orca/src/main/computer/desktop-script-runtime-host.ts
T
Neil 1912c90141 Extract subprocess handling into @orca/process-host (#26963)
Extract subprocess execution and process contracts into @orca/process-host with explicit public entry points, independent checks, and one Node 18 compatible CommonJS runtime. Migrate application consumers and integrate package builds, dependency selection, import enforcement, and runtime resource staging.

Preserve daemon startup ownership and profile isolation, and qualify development rebuild recovery and concurrent output publication. Remove unnecessary aliases, test forwarding exports, and a duplicate output scan.

Validation: full typechecks, changed-code quality, package and artifact checks, native Windows builds/types plus 306 runtime tests, and independent native reviews.

Matched full E2E controls on original main and unchanged reviewed head both pass all 17 applicable tests (nine skips). Earlier intermittent E2E failures are documented in the PR; no assertions were weakened. The final combined-tree audit preserves both branches with no import-boundary violations.
2026-10-09 18:32:28 -07:00

385 lines
14 KiB
TypeScript

import { spawnProcess } from '@orca/process-host'
import { windowsPowerShellPath } from '@orca/process-host/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)
}