diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0e2fa3f273c..0cd7960e0c7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index fd36a803bb9..f5916d6a79c 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -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', diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 4b68525c7c6..efd44e6cde7 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -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 }) + } } diff --git a/src/main/computer/computer-sidecar-diagnostics.test.ts b/src/main/computer/computer-sidecar-diagnostics.test.ts new file mode 100644 index 00000000000..06d1b1cfa02 --- /dev/null +++ b/src/main/computer/computer-sidecar-diagnostics.test.ts @@ -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) + }) +}) diff --git a/src/main/computer/computer-sidecar-diagnostics.ts b/src/main/computer/computer-sidecar-diagnostics.ts new file mode 100644 index 00000000000..2b23418940d --- /dev/null +++ b/src/main/computer/computer-sidecar-diagnostics.ts @@ -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 + 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}`) +} diff --git a/src/main/computer/desktop-script-action.ts b/src/main/computer/desktop-script-action.ts index 38dde4b56fa..7c2c21f1e8c 100644 --- a/src/main/computer/desktop-script-action.ts +++ b/src/main/computer/desktop-script-action.ts @@ -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) +} diff --git a/src/main/computer/desktop-script-provider-bridge.ts b/src/main/computer/desktop-script-provider-bridge.ts index c3c21496d29..fecc4036df1 100644 --- a/src/main/computer/desktop-script-provider-bridge.ts +++ b/src/main/computer/desktop-script-provider-bridge.ts @@ -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 | 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 }) diff --git a/src/main/computer/desktop-script-provider-client.ts b/src/main/computer/desktop-script-provider-client.ts index 5e8e01e0d44..a655eeddb07 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,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 { @@ -203,6 +209,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. + // 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 { 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) { 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..e50a3878a11 --- /dev/null +++ b/src/main/computer/desktop-script-provider-runtime-host-routing.test.ts @@ -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)]) + }) +}) diff --git a/src/main/computer/desktop-script-provider-test-harness.ts b/src/main/computer/desktop-script-provider-test-harness.ts index 2cbd1e9a776..bcb0a4b0118 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,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 }, diff --git a/src/main/computer/desktop-script-provider-types.ts b/src/main/computer/desktop-script-provider-types.ts index 0ff48e80b4f..6e474fdcf29 100644 --- a/src/main/computer/desktop-script-provider-types.ts +++ b/src/main/computer/desktop-script-provider-types.ts @@ -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 diff --git a/src/main/computer/desktop-script-request-queue.ts b/src/main/computer/desktop-script-request-queue.ts new file mode 100644 index 00000000000..8f32b5f6888 --- /dev/null +++ b/src/main/computer/desktop-script-request-queue.ts @@ -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 | 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(run: () => Promise): Promise { + const queued = this.tail + if (!queued) { + return this.track(run()) + } + let expiry: RuntimeClientError | null = null + let waitTimer: NodeJS.Timeout | undefined + const waited = new Promise((_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 => { + 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(result: Promise): Promise { + const tail = result.then( + () => undefined, + () => undefined + ) + this.tail = tail + void tail.finally(() => { + if (this.tail !== tail) { + return + } + this.tail = null + this.onDrained() + }) + return result + } +} diff --git a/src/main/computer/desktop-script-runtime-availability.ts b/src/main/computer/desktop-script-runtime-availability.ts new file mode 100644 index 00000000000..58123f5eab7 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-availability.ts @@ -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 + } +} 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..4d42a3c3e37 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-host.test.ts @@ -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[] { + return this.writes.map((line) => JSON.parse(line) as Record) + } + + /** 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, 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 { + 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 { + 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() + }) +}) 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..09aff5ec479 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-host.ts @@ -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 { + 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 { + 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 { + 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 + try { + parsed = JSON.parse(line) as Record + } 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) +} 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..76927a7dd65 --- /dev/null +++ b/src/main/computer/desktop-script-runtime-host.win32.test.ts @@ -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> { + 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 + try { + parsed = JSON.parse(line) as Record + } 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')) + } + } + ) + }) +} diff --git a/src/main/computer/desktop-script-serve-channel.test.ts b/src/main/computer/desktop-script-serve-channel.test.ts new file mode 100644 index 00000000000..80a5dd491d3 --- /dev/null +++ b/src/main/computer/desktop-script-serve-channel.test.ts @@ -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() + }) + }) +}) diff --git a/src/main/computer/desktop-script-serve-channel.ts b/src/main/computer/desktop-script-serve-channel.ts new file mode 100644 index 00000000000..afabb47962a --- /dev/null +++ b/src/main/computer/desktop-script-serve-channel.ts @@ -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 + +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) +} diff --git a/src/main/computer/sidecar-client.ts b/src/main/computer/sidecar-client.ts index 489c463dd93..23af1aa603b 100644 --- a/src/main/computer/sidecar-client.ts +++ b/src/main/computer/sidecar-client.ts @@ -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 } diff --git a/src/main/computer/sidecar-entry.ts b/src/main/computer/sidecar-entry.ts index 8489f71e7f3..961d2261ede 100644 --- a/src/main/computer/sidecar-entry.ts +++ b/src/main/computer/sidecar-entry.ts @@ -8,6 +8,11 @@ type SidecarRequest = { params?: Record } +// 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() diff --git a/src/main/computer/windows-powershell-execution-policy.test.ts b/src/main/computer/windows-powershell-execution-policy.test.ts new file mode 100644 index 00000000000..033eec58fb5 --- /dev/null +++ b/src/main/computer/windows-powershell-execution-policy.test.ts @@ -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' + ]) + }) +}) 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..204f5b02b06 --- /dev/null +++ b/src/main/computer/windows-powershell-execution-policy.ts @@ -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 + ] +}