From cee7fed592a7c971bf88fe508a2d38c835e82abb Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Tue, 1 Sep 2026 00:04:35 -0700 Subject: [PATCH] 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. --- native/computer-use-windows/runtime.ps1 | 41 ++- .../desktop-script-provider-bridge.ts | 52 ++- .../desktop-script-provider-client.ts | 42 ++- ...ript-provider-runtime-host-routing.test.ts | 99 ++++++ .../desktop-script-provider-test-harness.ts | 19 +- .../desktop-script-runtime-host.test.ts | 277 +++++++++++++++ .../computer/desktop-script-runtime-host.ts | 333 ++++++++++++++++++ .../desktop-script-runtime-host.win32.test.ts | 65 ++++ .../windows-powershell-execution-policy.ts | 38 ++ 9 files changed, 937 insertions(+), 29 deletions(-) create mode 100644 src/main/computer/desktop-script-provider-runtime-host-routing.test.ts create mode 100644 src/main/computer/desktop-script-runtime-host.test.ts create mode 100644 src/main/computer/desktop-script-runtime-host.ts create mode 100644 src/main/computer/desktop-script-runtime-host.win32.test.ts create mode 100644 src/main/computer/windows-powershell-execution-policy.ts diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 4b68525c7c6..93875053c18 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -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 }) + } } diff --git a/src/main/computer/desktop-script-provider-bridge.ts b/src/main/computer/desktop-script-provider-bridge.ts index c3c21496d29..3148a6d521e 100644 --- a/src/main/computer/desktop-script-provider-bridge.ts +++ b/src/main/computer/desktop-script-provider-bridge.ts @@ -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 | 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, diff --git a/src/main/computer/desktop-script-provider-client.ts b/src/main/computer/desktop-script-provider-client.ts index 5e8e01e0d44..4a3e31d0c16 100644 --- a/src/main/computer/desktop-script-provider-client.ts +++ b/src/main/computer/desktop-script-provider-client.ts @@ -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 { @@ -203,6 +206,23 @@ export class DesktopScriptProviderClient { } private async callBridge(request: BridgeRequest): Promise { + 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 { 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) { diff --git a/src/main/computer/desktop-script-provider-runtime-host-routing.test.ts b/src/main/computer/desktop-script-provider-runtime-host-routing.test.ts new file mode 100644 index 00000000000..83cf3b7e791 --- /dev/null +++ b/src/main/computer/desktop-script-provider-runtime-host-routing.test.ts @@ -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)]) + }) +}) diff --git a/src/main/computer/desktop-script-provider-test-harness.ts b/src/main/computer/desktop-script-provider-test-harness.ts index 2cbd1e9a776..e44563c3a65 100644 --- a/src/main/computer/desktop-script-provider-test-harness.ts +++ b/src/main/computer/desktop-script-provider-test-harness.ts @@ -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() @@ -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 }, diff --git a/src/main/computer/desktop-script-runtime-host.test.ts b/src/main/computer/desktop-script-runtime-host.test.ts new file mode 100644 index 00000000000..65dab32f8d9 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-host.test.ts @@ -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[] { + return this.writes.map((line) => JSON.parse(line) as Record) + } + + 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 + } + }) + return { host, children, specs } +} + +/** Let the host's queue microtasks drain so the next request reaches its child. */ +async function settle(): Promise { + 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) + }) +}) diff --git a/src/main/computer/desktop-script-runtime-host.ts b/src/main/computer/desktop-script-runtime-host.ts new file mode 100644 index 00000000000..440e671f1b6 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-host.ts @@ -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 + +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 | 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 { + const run = (): Promise => 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 { + 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 { + 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) + } +} diff --git a/src/main/computer/desktop-script-runtime-host.win32.test.ts b/src/main/computer/desktop-script-runtime-host.win32.test.ts new file mode 100644 index 00000000000..22d007372f3 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-host.win32.test.ts @@ -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) + }) +}) diff --git a/src/main/computer/windows-powershell-execution-policy.ts b/src/main/computer/windows-powershell-execution-policy.ts new file mode 100644 index 00000000000..07d2752dcf8 --- /dev/null +++ b/src/main/computer/windows-powershell-execution-policy.ts @@ -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 + ] +}