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.
This commit is contained in:
Orca Worker
2026-09-01 00:04:35 -07:00
parent e7f15367a8
commit cee7fed592
9 changed files with 937 additions and 29 deletions
+34 -7
View File
@@ -1,6 +1,9 @@
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"
@@ -1313,9 +1316,33 @@ 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 {
# 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 }
try {
$json = ConvertTo-Json (Invoke-OrcaOperation ($line | ConvertFrom-Json)) -Depth 100 -Compress
} catch {
$json = ConvertTo-Json ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message }) -Depth 100 -Compress
}
[Console]::Out.WriteLine($json)
[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 })
}
}
@@ -1,28 +1,50 @@
import { execFile } from 'node:child_process'
import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary'
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 runBridgeProcess('python3', [scriptPath, operationPath])
}
const command = windowsPowerShellPath()
try {
return await runBridgeProcess(
command,
windowsPowerShellRuntimeArgs(scriptPath, PREFERRED_WINDOWS_EXECUTION_POLICY, [operationPath])
)
} catch (error) {
// The script never loaded under a blocking policy, so re-running is safe.
if (!isExecutionPolicyBlocked(error instanceof Error ? error.message : String(error))) {
throw error
}
console.warn(
`[computer-use] bridge start blocked at ${PREFERRED_WINDOWS_EXECUTION_POLICY}; retrying once with ${FALLBACK_WINDOWS_EXECUTION_POLICY}`
)
return await runBridgeProcess(
command,
windowsPowerShellRuntimeArgs(scriptPath, FALLBACK_WINDOWS_EXECUTION_POLICY, [operationPath])
)
}
}
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 +98,7 @@ export function execBridge(
try {
child = execFile(
command,
args,
[...args],
{
env: process.env,
maxBuffer: 20 * 1024 * 1024,
@@ -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,14 @@ export class DesktopScriptProviderClient {
constructor(
private readonly platform: DesktopScriptPlatform = requiredPlatform(),
private readonly scriptPath: string = requiredScriptPath()
private readonly scriptPath: string = requiredScriptPath(),
private runtimeHost: DesktopScriptRuntimeHost | null = defaultRuntimeHost(platform, scriptPath)
) {}
shutdown(): void {
this.snapshotStore.clear()
this.providerCapabilities = null
this.runtimeHost?.dispose()
}
async listApps(): Promise<ComputerListAppsResult> {
@@ -203,6 +206,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.
if (!isRuntimeHostUnavailable(error)) {
throw error
}
this.runtimeHost = null
host.dispose()
}
}
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 +237,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 +272,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,99 @@
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 when the runtime host cannot start', 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 }] })
expect(dispose).toHaveBeenCalled()
// The host is dropped for the session rather than re-probed per operation.
await client.listApps()
expect(request).toHaveBeenCalledTimes(1)
expectDesktopProviderSubprocessStartCount(2)
})
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('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('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,18 @@ export function mockBridgeResponse(
})
}
export function mockBridgeProcessFailure(stderr: string): void {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
const done = callback as (error: Error | null, stdout: string, stderr: string) => void
done(new Error('Command failed'), '', 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 },
@@ -0,0 +1,277 @@
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ProcessSpec } from '../../shared/child-process/process-spec'
import type { spawnProcess } from '../../shared/child-process/run-process'
import { DesktopScriptRuntimeHost, isRuntimeHostUnavailable } from './desktop-script-runtime-host'
const POLICY_ERROR =
'File runtime.ps1 cannot be loaded because running scripts is 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
readonly stdin = {
write: (chunk: string, callback?: (error?: Error | null) => void): boolean => {
this.writes.push(chunk)
callback?.(null)
return true
},
end: (): void => {
this.stdinEnded = true
},
on: (): void => {}
}
kill(): boolean {
this.killed = true
return true
}
/** Requests written to this child, decoded. */
requests(): Record<string, unknown>[] {
return this.writes.map((line) => JSON.parse(line) as Record<string, unknown>)
}
respond(response: unknown): void {
this.stdout.emit('data', Buffer.from(`${JSON.stringify(response)}\n`, 'utf8'))
}
exit(code: number | null, stderr = ''): void {
if (stderr) {
this.stderr.emit('data', Buffer.from(stderr, 'utf8'))
}
this.emit('exit', code, null)
}
}
function createHost(options: { idleShutdownMs?: number; requestTimeoutMs?: number } = {}) {
const children: FakeRuntimeChild[] = []
const specs: ProcessSpec[] = []
const host = new DesktopScriptRuntimeHost('C:\\orca\\runtime.ps1', {
...options,
powerShellPath: () => 'C:\\Windows\\System32\\powershell.exe',
warn: () => {},
spawn: (spec) => {
specs.push(spec)
const child = new FakeRuntimeChild()
children.push(child)
return child as unknown as ReturnType<typeof spawnProcess>
}
})
return { host, children, specs }
}
/** 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()
}
}
describe('DesktopScriptRuntimeHost', () => {
afterEach(() => {
vi.useRealTimers()
})
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([
'-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' }])
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('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é' } })}\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('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('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('falls back to Bypass once when the execution policy blocks the start', async () => {
const { host, children, specs } = 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 })
// 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
host.dispose()
})
it('reports itself unavailable when Bypass is also refused', async () => {
const { host, children } = createHost()
const promise = host.request({ tool: 'handshake' })
await settle()
children[0].exit(1, POLICY_ERROR)
await settle()
children[1].exit(1, POLICY_ERROR)
await expect(promise).rejects.toSatisfy(isRuntimeHostUnavailable)
await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable)
})
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)
})
it('reports itself unavailable when a fresh helper dies before answering', async () => {
const { host, children } = createHost()
const promise = host.request({ tool: 'handshake' })
await settle()
children[0].exit(1, 'The term is not recognized')
await expect(promise).rejects.toSatisfy(isRuntimeHostUnavailable)
})
})
@@ -0,0 +1,333 @@
import { StringDecoder } from 'node:string_decoder'
import type { ProcessSpec } from '../../shared/child-process/process-spec'
import { spawnProcess } from '../../shared/child-process/run-process'
import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary'
import type { BridgeRequest, BridgeResponse } from './desktop-script-provider-types'
import { RuntimeClientError } from './runtime-client-error'
import {
FALLBACK_WINDOWS_EXECUTION_POLICY,
PREFERRED_WINDOWS_EXECUTION_POLICY,
isExecutionPolicyBlocked,
windowsPowerShellRuntimeArgs,
type WindowsExecutionPolicy
} from './windows-powershell-execution-policy'
/** The all-pipes child `spawnProcess` returns; avoids a node:child_process import. */
type RuntimeChildProcess = ReturnType<typeof spawnProcess>
const REQUEST_TIMEOUT_MS = 30_000
const IDLE_SHUTDOWN_MS = 120_000
const MAX_RESPONSE_BYTES = 20 * 1024 * 1024
/** Code the client keys on to fall back to the one-shot bridge for the session. */
export const RUNTIME_HOST_UNAVAILABLE = 'runtime_host_unavailable'
export type DesktopScriptRuntimeHostOptions = {
spawn?: (spec: ProcessSpec) => RuntimeChildProcess
powerShellPath?: () => string
requestTimeoutMs?: number
idleShutdownMs?: number
warn?: (message: string) => void
}
type PendingRequest = {
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: the protocol carries no request id because
* only one operation is ever in flight, and native automation is not safe to
* interleave anyway.
*/
export class DesktopScriptRuntimeHost {
private child: RuntimeChildProcess | null = null
private detachChild: (() => void) | null = null
private decoder = new StringDecoder('utf8')
private stdoutBuffer = ''
private stderrText = ''
private pending: PendingRequest | null = null
private queueTail: Promise<void> | null = null
private idleTimer: NodeJS.Timeout | null = null
private policy: WindowsExecutionPolicy = PREFERRED_WINDOWS_EXECUTION_POLICY
private policyRetryPending = false
private childAnswered = false
private unavailable = false
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
}
request(request: BridgeRequest): Promise<BridgeResponse> {
const run = (): Promise<BridgeResponse> => this.send(request)
const result = this.queueTail ? this.queueTail.then(run, run) : run()
const tail = result.then(
() => undefined,
() => undefined
)
this.queueTail = tail
void tail.finally(() => {
if (this.queueTail !== tail) {
return
}
this.queueTail = null
this.armIdleTimer()
})
return result
}
/** Stop the helper. A later request starts a fresh one. */
dispose(): void {
this.clearIdleTimer()
this.stopChild()
this.rejectPending(
new RuntimeClientError('accessibility_error', 'desktop provider runtime host was shut down')
)
}
private async send(request: BridgeRequest): Promise<BridgeResponse> {
this.clearIdleTimer()
try {
return await this.sendOnce(request)
} catch (error) {
if (!this.policyRetryPending) {
throw error
}
this.policyRetryPending = false
this.policy = FALLBACK_WINDOWS_EXECUTION_POLICY
this.warn(
`runtime host start blocked at ${PREFERRED_WINDOWS_EXECUTION_POLICY}; retrying once with ${FALLBACK_WINDOWS_EXECUTION_POLICY}`
)
return await this.sendOnce(request)
}
}
private sendOnce(request: BridgeRequest): Promise<BridgeResponse> {
if (this.unavailable) {
return Promise.reject(this.unavailableError('runtime host is unavailable'))
}
let child: RuntimeChildProcess
try {
child = this.ensureChild()
} catch (error) {
this.unavailable = true
return Promise.reject(
this.unavailableError(error instanceof Error ? error.message : String(error))
)
}
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.abortChild(
new RuntimeClientError(
'action_timeout',
`desktop provider timed out after ${this.requestTimeoutMs}ms`
)
)
}, this.requestTimeoutMs)
timer.unref?.()
this.pending = { resolve, reject, timer }
child.stdin.write(`${JSON.stringify(request)}\n`, (error) => {
if (error) {
this.abortChild(new RuntimeClientError('accessibility_error', error.message))
}
})
})
}
private ensureChild(): RuntimeChildProcess {
if (this.child) {
return this.child
}
const spawn = this.options.spawn ?? spawnProcess
const child = spawn({
program: (this.options.powerShellPath ?? windowsPowerShellPath)(),
args: windowsPowerShellRuntimeArgs(this.scriptPath, this.policy, ['-Serve']),
env: process.env
})
this.decoder = new StringDecoder('utf8')
this.stdoutBuffer = ''
this.stderrText = ''
this.childAnswered = false
const onStdout = (chunk: Buffer | string): void => this.readStdout(chunk)
const onStderr = (chunk: Buffer | string): void => {
this.stderrText = `${this.stderrText}${chunk.toString()}`.slice(-4096)
}
const onExit = (code: number | null, signal: NodeJS.Signals | null): void =>
this.handleGone(child, signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`)
const onError = (error: Error): void => this.handleGone(child, error.message)
child.stdout.on('data', onStdout)
child.stderr.on('data', onStderr)
child.once('exit', onExit)
child.once('error', onError)
// An unhandled stream error is an uncaught exception in the main process.
child.stdin.on('error', () => {})
this.detachChild = (): void => {
child.stdout.off('data', onStdout)
child.stderr.off('data', onStderr)
child.off('exit', onExit)
child.off('error', onError)
child.on('error', () => {})
}
this.child = child
return child
}
private readStdout(chunk: Buffer | string): void {
this.stdoutBuffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk)
if (this.stdoutBuffer.length > MAX_RESPONSE_BYTES) {
this.abortChild(
new RuntimeClientError(
'accessibility_error',
'desktop provider response exceeded the runtime host buffer'
)
)
return
}
for (let newline = this.stdoutBuffer.indexOf('\n'); newline >= 0;) {
const line = this.stdoutBuffer.slice(0, newline).trim()
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1)
if (line) {
this.deliver(line)
}
newline = this.stdoutBuffer.indexOf('\n')
}
}
private deliver(line: string): void {
this.childAnswered = true
const pending = this.takePending()
if (!pending) {
return
}
try {
pending.resolve(JSON.parse(line) as BridgeResponse)
} catch (error) {
pending.reject(
new RuntimeClientError(
'accessibility_error',
`desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`
)
)
}
}
private handleGone(child: RuntimeChildProcess, detail: string): void {
// A replaced child can still report; that must not fail the live one.
if (this.child !== child) {
return
}
const text = [detail, this.stderrText.trim()].filter(Boolean).join(': ')
const answered = this.childAnswered
this.releaseChild()
if (
!answered &&
this.policy === PREFERRED_WINDOWS_EXECUTION_POLICY &&
isExecutionPolicyBlocked(text)
) {
this.policyRetryPending = true
this.rejectPending(new RuntimeClientError('accessibility_error', text))
return
}
if (!answered) {
this.unavailable = true
this.rejectPending(this.unavailableError(text))
return
}
this.rejectPending(
new RuntimeClientError('accessibility_error', `desktop provider runtime host exited: ${text}`)
)
}
private abortChild(error: Error): void {
this.stopChild()
this.rejectPending(error)
}
private stopChild(): void {
const child = this.child
this.releaseChild()
if (!child) {
return
}
// Closing stdin ends the serve loop; the kill covers a wedged helper.
try {
child.stdin.end()
} catch {
/* already closed */
}
child.kill()
}
private releaseChild(): void {
const detach = this.detachChild
this.detachChild = null
detach?.()
this.child = null
}
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.child) {
return
}
this.idleTimer = setTimeout(() => {
this.idleTimer = null
this.stopChild()
}, 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}`
)
}
private warn(message: string): void {
;(this.options.warn ?? ((text: string) => console.warn(`[computer-use] ${text}`)))(message)
}
}
@@ -0,0 +1,65 @@
import { resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { spawnProcess } from '../../shared/child-process/run-process'
import { DesktopScriptRuntimeHost } from './desktop-script-runtime-host'
/**
* 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)
})
})
@@ -0,0 +1,38 @@
/**
* 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.
const EXECUTION_POLICY_BLOCKED =
/running scripts is disabled on this system|UnauthorizedAccess|PSSecurityException|SecurityError|about_Execution_Policies/i
export function isExecutionPolicyBlocked(text: string): boolean {
return EXECUTION_POLICY_BLOCKED.test(text)
}
export function windowsPowerShellRuntimeArgs(
scriptPath: string,
policy: WindowsExecutionPolicy,
scriptArgs: readonly string[] = []
): string[] {
return [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
policy,
'-File',
scriptPath,
...scriptArgs
]
}