mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(computer-use): run the Windows runtime as one persistent helper
Microsoft Defender for Endpoint raised multi-stage Execution + Collection
incidents against Orca on Windows ("Screenshots were taken unexpectedly on
this device... Screen capture code was found in a script launched by
powershell.exe", factor "Executes suspicious MSIL code"). The desktop script
provider spawned a fresh powershell.exe per operation, so a single computer-use
session produced a burst of short-lived PIDs and re-emitted runtime.ps1's
inline Add-Type P/Invoke assembly on every click.
runtime.ps1 gains a -Serve mode that loads its assemblies once and then reads
NDJSON requests from stdin, and a new DesktopScriptRuntimeHost owns one
long-lived child: lazy spawn, strict serialization, a 30s per-request timeout,
restart on crash, a 120s idle shutdown, and dispose() on provider teardown. The
one-shot -OperationPath path stays as the fallback, and Linux keeps its python3
bridge unchanged.
Both Windows spawn sites now use -ExecutionPolicy RemoteSigned instead of
Bypass, falling back once to Bypass (and logging) when a Restricted host
refuses the unsigned script.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OperationPath
|
||||
[Parameter(Position = 0)]
|
||||
[string]$OperationPath,
|
||||
# Serve mode keeps one process alive so the Add-Type P/Invoke assembly below
|
||||
# is emitted once per session instead of once per operation.
|
||||
[switch]$Serve
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -1313,9 +1316,33 @@ function Invoke-OrcaOperation($Operation) {
|
||||
[pscustomobject]@{ ok = $true; action = $action; snapshot = $snapshot }
|
||||
}
|
||||
|
||||
try {
|
||||
$operation = Read-OrcaOperation $OperationPath
|
||||
Write-OrcaJson (Invoke-OrcaOperation $operation)
|
||||
} catch {
|
||||
Write-OrcaJson ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message })
|
||||
function Invoke-OrcaServeLoop {
|
||||
# One NDJSON request per line in, one response per line out, until stdin closes.
|
||||
# Responses carry base64 screenshots and routinely exceed a megabyte; ReadLine
|
||||
# and the console writer are both length-bounded only by memory.
|
||||
while ($true) {
|
||||
$line = [Console]::In.ReadLine()
|
||||
if ($null -eq $line) { break }
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
try {
|
||||
$json = ConvertTo-Json (Invoke-OrcaOperation ($line | ConvertFrom-Json)) -Depth 100 -Compress
|
||||
} catch {
|
||||
$json = ConvertTo-Json ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message }) -Depth 100 -Compress
|
||||
}
|
||||
[Console]::Out.WriteLine($json)
|
||||
[Console]::Out.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
if ($Serve) {
|
||||
Invoke-OrcaServeLoop
|
||||
} elseif ([string]::IsNullOrWhiteSpace($OperationPath)) {
|
||||
Write-OrcaJson ([pscustomobject]@{ ok = $false; error = "runtime.ps1 requires an operation path or -Serve" })
|
||||
} else {
|
||||
try {
|
||||
$operation = Read-OrcaOperation $OperationPath
|
||||
Write-OrcaJson (Invoke-OrcaOperation $operation)
|
||||
} catch {
|
||||
Write-OrcaJson ([pscustomobject]@{ ok = $false; error = [string]$_.Exception.Message })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,50 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
import type { DesktopScriptPlatform } from './desktop-script-provider-paths'
|
||||
import {
|
||||
FALLBACK_WINDOWS_EXECUTION_POLICY,
|
||||
PREFERRED_WINDOWS_EXECUTION_POLICY,
|
||||
isExecutionPolicyBlocked,
|
||||
windowsPowerShellRuntimeArgs
|
||||
} from './windows-powershell-execution-policy'
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const FORCE_KILL_GRACE_MS = 1_000
|
||||
|
||||
export function execBridge(
|
||||
export async function execBridge(
|
||||
platform: DesktopScriptPlatform,
|
||||
scriptPath: string,
|
||||
operationPath: string
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const command = platform === 'windows' ? 'powershell.exe' : 'python3'
|
||||
const args =
|
||||
platform === 'windows'
|
||||
? [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
scriptPath,
|
||||
operationPath
|
||||
]
|
||||
: [scriptPath, operationPath]
|
||||
if (platform !== 'windows') {
|
||||
return await runBridgeProcess('python3', [scriptPath, operationPath])
|
||||
}
|
||||
const command = windowsPowerShellPath()
|
||||
try {
|
||||
return await runBridgeProcess(
|
||||
command,
|
||||
windowsPowerShellRuntimeArgs(scriptPath, PREFERRED_WINDOWS_EXECUTION_POLICY, [operationPath])
|
||||
)
|
||||
} catch (error) {
|
||||
// The script never loaded under a blocking policy, so re-running is safe.
|
||||
if (!isExecutionPolicyBlocked(error instanceof Error ? error.message : String(error))) {
|
||||
throw error
|
||||
}
|
||||
console.warn(
|
||||
`[computer-use] bridge start blocked at ${PREFERRED_WINDOWS_EXECUTION_POLICY}; retrying once with ${FALLBACK_WINDOWS_EXECUTION_POLICY}`
|
||||
)
|
||||
return await runBridgeProcess(
|
||||
command,
|
||||
windowsPowerShellRuntimeArgs(scriptPath, FALLBACK_WINDOWS_EXECUTION_POLICY, [operationPath])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function runBridgeProcess(
|
||||
command: string,
|
||||
args: readonly string[]
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof execFile> | null = null
|
||||
let settled = false
|
||||
@@ -76,7 +98,7 @@ export function execBridge(
|
||||
try {
|
||||
child = execFile(
|
||||
command,
|
||||
args,
|
||||
[...args],
|
||||
{
|
||||
env: process.env,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
BridgeResponse,
|
||||
NativeActionMethod
|
||||
} from './desktop-script-provider-types'
|
||||
import { DesktopScriptRuntimeHost, isRuntimeHostUnavailable } from './desktop-script-runtime-host'
|
||||
import { DesktopScriptSnapshotStore } from './desktop-script-snapshot-store'
|
||||
import { normalizeBridgeApp, renderSnapshot } from './desktop-script-snapshot-rendering'
|
||||
import { normalizeComputerActionResult } from './computer-action-verification-normalization'
|
||||
@@ -51,12 +52,14 @@ export class DesktopScriptProviderClient {
|
||||
|
||||
constructor(
|
||||
private readonly platform: DesktopScriptPlatform = requiredPlatform(),
|
||||
private readonly scriptPath: string = requiredScriptPath()
|
||||
private readonly scriptPath: string = requiredScriptPath(),
|
||||
private runtimeHost: DesktopScriptRuntimeHost | null = defaultRuntimeHost(platform, scriptPath)
|
||||
) {}
|
||||
|
||||
shutdown(): void {
|
||||
this.snapshotStore.clear()
|
||||
this.providerCapabilities = null
|
||||
this.runtimeHost?.dispose()
|
||||
}
|
||||
|
||||
async listApps(): Promise<ComputerListAppsResult> {
|
||||
@@ -203,6 +206,23 @@ export class DesktopScriptProviderClient {
|
||||
}
|
||||
|
||||
private async callBridge(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
const host = this.runtimeHost
|
||||
if (host) {
|
||||
try {
|
||||
return checkedBridgeResponse(await host.request(request), '')
|
||||
} catch (error) {
|
||||
// Only a helper that cannot start falls back; operation errors surface.
|
||||
if (!isRuntimeHostUnavailable(error)) {
|
||||
throw error
|
||||
}
|
||||
this.runtimeHost = null
|
||||
host.dispose()
|
||||
}
|
||||
}
|
||||
return await this.callOneShotBridge(request)
|
||||
}
|
||||
|
||||
private async callOneShotBridge(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
const operationDirectory = await mkdtemp(join(tmpdir(), 'orca-computer-use-'))
|
||||
const operationPath = join(operationDirectory, 'operation.json')
|
||||
try {
|
||||
@@ -217,10 +237,7 @@ export class DesktopScriptProviderClient {
|
||||
`desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw mapBridgeError(response.error ?? stderr)
|
||||
}
|
||||
return response
|
||||
return checkedBridgeResponse(response, stderr)
|
||||
} finally {
|
||||
await rm(operationDirectory, { force: true, recursive: true })
|
||||
}
|
||||
@@ -255,6 +272,21 @@ export class DesktopScriptProviderClient {
|
||||
}
|
||||
}
|
||||
|
||||
function checkedBridgeResponse(response: BridgeResponse, stderr: string): BridgeResponse {
|
||||
if (!response.ok) {
|
||||
throw mapBridgeError(response.error ?? stderr)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// Why Windows only: the Linux provider is a python3 one-shot with no serve mode.
|
||||
function defaultRuntimeHost(
|
||||
platform: DesktopScriptPlatform,
|
||||
scriptPath: string
|
||||
): DesktopScriptRuntimeHost | null {
|
||||
return platform === 'windows' ? new DesktopScriptRuntimeHost(scriptPath) : null
|
||||
}
|
||||
|
||||
function requiredPlatform(): DesktopScriptPlatform {
|
||||
const platform = desktopScriptPlatform()
|
||||
if (!platform) {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
bridgeProcessArgs,
|
||||
createDesktopScriptProviderClient,
|
||||
expectDesktopProviderSubprocessStartCount,
|
||||
mockBridgeProcessFailure,
|
||||
mockBridgeResponse,
|
||||
resetDesktopScriptProviderTestHarness,
|
||||
sampleCapabilities
|
||||
} from './desktop-script-provider-test-harness'
|
||||
import type { BridgeResponse } from './desktop-script-provider-types'
|
||||
import type { DesktopScriptRuntimeHost } from './desktop-script-runtime-host'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
|
||||
const POLICY_STDERR =
|
||||
'File runtime.ps1 cannot be loaded because running scripts is disabled on this system. + CategoryInfo : SecurityError'
|
||||
|
||||
function fakeRuntimeHost(request: DesktopScriptRuntimeHost['request']) {
|
||||
const dispose = vi.fn()
|
||||
return { host: { request, dispose } as unknown as DesktopScriptRuntimeHost, dispose }
|
||||
}
|
||||
|
||||
describe('desktop script provider runtime host routing', () => {
|
||||
afterEach(resetDesktopScriptProviderTestHarness)
|
||||
|
||||
it('serves Windows operations from the runtime host without spawning a one-shot bridge', async () => {
|
||||
const request = vi.fn(
|
||||
async () => ({ ok: true, capabilities: sampleCapabilities() }) as BridgeResponse
|
||||
)
|
||||
const { host } = fakeRuntimeHost(request)
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host)
|
||||
|
||||
await expect(client.capabilities()).resolves.toMatchObject({ platform: 'linux' })
|
||||
expect(request).toHaveBeenCalledWith({ tool: 'handshake' })
|
||||
expectDesktopProviderSubprocessStartCount(0)
|
||||
})
|
||||
|
||||
it('maps runtime host operation failures without falling back to the one-shot bridge', async () => {
|
||||
const { host } = fakeRuntimeHost(
|
||||
vi.fn(async () => ({ ok: false, error: 'appBlocked("1Password")' }) as BridgeResponse)
|
||||
)
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host)
|
||||
|
||||
await expect(client.listApps()).rejects.toMatchObject({ code: 'app_blocked' })
|
||||
expectDesktopProviderSubprocessStartCount(0)
|
||||
})
|
||||
|
||||
it('degrades to the one-shot bridge when the runtime host cannot start', async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new RuntimeClientError('runtime_host_unavailable', 'could not start')
|
||||
})
|
||||
const { host, dispose } = fakeRuntimeHost(request as never)
|
||||
mockBridgeResponse({ ok: true, apps: [{ name: 'Notepad', pid: 42 }] })
|
||||
mockBridgeResponse({ ok: true, apps: [{ name: 'Notepad', pid: 42 }] })
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1', host)
|
||||
|
||||
await expect(client.listApps()).resolves.toMatchObject({ apps: [{ pid: 42 }] })
|
||||
expect(dispose).toHaveBeenCalled()
|
||||
|
||||
// The host is dropped for the session rather than re-probed per operation.
|
||||
await client.listApps()
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expectDesktopProviderSubprocessStartCount(2)
|
||||
})
|
||||
|
||||
it('runs the one-shot bridge under RemoteSigned and falls back to Bypass once', async () => {
|
||||
mockBridgeProcessFailure(POLICY_STDERR)
|
||||
mockBridgeResponse({ ok: true, apps: [] })
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1')
|
||||
|
||||
await expect(client.listApps()).resolves.toEqual({ apps: [] })
|
||||
expectDesktopProviderSubprocessStartCount(2)
|
||||
expect(bridgeProcessArgs(0)).toContain('RemoteSigned')
|
||||
expect(bridgeProcessArgs(0)).not.toContain('Bypass')
|
||||
expect(bridgeProcessArgs(1)).toContain('Bypass')
|
||||
})
|
||||
|
||||
it('does not retry the one-shot bridge for a non-policy failure', async () => {
|
||||
mockBridgeProcessFailure('No top-level UI Automation window is available for Notepad')
|
||||
|
||||
const client = await createDesktopScriptProviderClient('windows', 'C:\\runtime.ps1')
|
||||
|
||||
await expect(client.listApps()).rejects.toMatchObject({ code: 'window_not_found' })
|
||||
expectDesktopProviderSubprocessStartCount(1)
|
||||
})
|
||||
|
||||
it('keeps Linux on the one-shot python bridge with no execution policy flags', async () => {
|
||||
mockBridgeResponse({ ok: true, apps: [] })
|
||||
|
||||
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
|
||||
|
||||
await expect(client.listApps()).resolves.toEqual({ apps: [] })
|
||||
expect(bridgeProcessArgs(0)).toEqual(['/tmp/runtime.py', expect.any(String)])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, vi } from 'vitest'
|
||||
import type { DesktopScriptRuntimeHost } from './desktop-script-runtime-host'
|
||||
|
||||
const { execFileMock, operationFiles, mkdtempMock, rmMock, writeFileMock } = vi.hoisted(() => {
|
||||
const files = new Map<string, string>()
|
||||
@@ -23,12 +24,14 @@ vi.mock('fs/promises', () => ({
|
||||
writeFile: writeFileMock
|
||||
}))
|
||||
|
||||
/** Builds a client on the one-shot bridge; pass a host to exercise serve mode. */
|
||||
export async function createDesktopScriptProviderClient(
|
||||
platform: 'linux' | 'windows',
|
||||
executablePath: string
|
||||
executablePath: string,
|
||||
runtimeHost: DesktopScriptRuntimeHost | null = null
|
||||
) {
|
||||
const { DesktopScriptProviderClient } = await import('./desktop-script-provider-client')
|
||||
return new DesktopScriptProviderClient(platform, executablePath)
|
||||
return new DesktopScriptProviderClient(platform, executablePath, runtimeHost)
|
||||
}
|
||||
|
||||
export function resetDesktopScriptProviderTestHarness(): void {
|
||||
@@ -77,6 +80,18 @@ export function mockBridgeResponse(
|
||||
})
|
||||
}
|
||||
|
||||
export function mockBridgeProcessFailure(stderr: string): void {
|
||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||
const done = callback as (error: Error | null, stdout: string, stderr: string) => void
|
||||
done(new Error('Command failed'), '', stderr)
|
||||
return null as never
|
||||
})
|
||||
}
|
||||
|
||||
export function bridgeProcessArgs(call: number): string[] {
|
||||
return (execFileMock.mock.calls[call]?.[1] ?? []) as string[]
|
||||
}
|
||||
|
||||
export function sampleBridgeSnapshot(name: string, value: string) {
|
||||
return {
|
||||
app: { name, bundleIdentifier: name, pid: 100 },
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessSpec } from '../../shared/child-process/process-spec'
|
||||
import type { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { DesktopScriptRuntimeHost, isRuntimeHostUnavailable } from './desktop-script-runtime-host'
|
||||
|
||||
const POLICY_ERROR =
|
||||
'File runtime.ps1 cannot be loaded because running scripts is disabled on this system.\n + CategoryInfo : SecurityError'
|
||||
|
||||
class FakeRuntimeChild extends EventEmitter {
|
||||
readonly stdout = new EventEmitter()
|
||||
readonly stderr = new EventEmitter()
|
||||
readonly writes: string[] = []
|
||||
killed = false
|
||||
stdinEnded = false
|
||||
readonly stdin = {
|
||||
write: (chunk: string, callback?: (error?: Error | null) => void): boolean => {
|
||||
this.writes.push(chunk)
|
||||
callback?.(null)
|
||||
return true
|
||||
},
|
||||
end: (): void => {
|
||||
this.stdinEnded = true
|
||||
},
|
||||
on: (): void => {}
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
this.killed = true
|
||||
return true
|
||||
}
|
||||
|
||||
/** Requests written to this child, decoded. */
|
||||
requests(): Record<string, unknown>[] {
|
||||
return this.writes.map((line) => JSON.parse(line) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
respond(response: unknown): void {
|
||||
this.stdout.emit('data', Buffer.from(`${JSON.stringify(response)}\n`, 'utf8'))
|
||||
}
|
||||
|
||||
exit(code: number | null, stderr = ''): void {
|
||||
if (stderr) {
|
||||
this.stderr.emit('data', Buffer.from(stderr, 'utf8'))
|
||||
}
|
||||
this.emit('exit', code, null)
|
||||
}
|
||||
}
|
||||
|
||||
function createHost(options: { idleShutdownMs?: number; requestTimeoutMs?: number } = {}) {
|
||||
const children: FakeRuntimeChild[] = []
|
||||
const specs: ProcessSpec[] = []
|
||||
const host = new DesktopScriptRuntimeHost('C:\\orca\\runtime.ps1', {
|
||||
...options,
|
||||
powerShellPath: () => 'C:\\Windows\\System32\\powershell.exe',
|
||||
warn: () => {},
|
||||
spawn: (spec) => {
|
||||
specs.push(spec)
|
||||
const child = new FakeRuntimeChild()
|
||||
children.push(child)
|
||||
return child as unknown as ReturnType<typeof spawnProcess>
|
||||
}
|
||||
})
|
||||
return { host, children, specs }
|
||||
}
|
||||
|
||||
/** Let the host's queue microtasks drain so the next request reaches its child. */
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 6; index++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('DesktopScriptRuntimeHost', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts one helper for many operations and never writes an operation file', async () => {
|
||||
const { host, children, specs } = createHost()
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await expect(first).resolves.toMatchObject({ ok: true })
|
||||
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const next = host.request({ tool: 'click', app: 'Notepad' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, action: { path: 'synthetic' } })
|
||||
await expect(next).resolves.toMatchObject({ ok: true })
|
||||
}
|
||||
|
||||
expect(children).toHaveLength(1)
|
||||
expect(children[0].requests()).toHaveLength(6)
|
||||
expect(specs[0].args).toEqual([
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'RemoteSigned',
|
||||
'-File',
|
||||
'C:\\orca\\runtime.ps1',
|
||||
'-Serve'
|
||||
])
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('serializes requests so only one operation is ever in flight', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const first = host.request({ tool: 'click', app: 'A' })
|
||||
const second = host.request({ tool: 'click', app: 'B' })
|
||||
await settle()
|
||||
|
||||
expect(children[0].requests()).toEqual([{ tool: 'click', app: 'A' }])
|
||||
|
||||
children[0].respond({ ok: true, action: { path: 'synthetic' } })
|
||||
await expect(first).resolves.toMatchObject({ ok: true })
|
||||
await settle()
|
||||
|
||||
expect(children[0].requests()).toHaveLength(2)
|
||||
children[0].respond({ ok: true, action: { path: 'accessibility' } })
|
||||
await expect(second).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('reassembles a response split across chunks, including a split code point', async () => {
|
||||
const { host, children } = createHost()
|
||||
const promise = host.request({ tool: 'get_app_state', app: 'Editor' })
|
||||
await settle()
|
||||
|
||||
const payload = Buffer.from(
|
||||
`${JSON.stringify({ ok: true, snapshot: { app: 'né' } })}\n`,
|
||||
'utf8'
|
||||
)
|
||||
const split = payload.indexOf(Buffer.from('é', 'utf8')) + 1
|
||||
children[0].stdout.emit('data', payload.subarray(0, split))
|
||||
children[0].stdout.emit('data', payload.subarray(split))
|
||||
|
||||
await expect(promise).resolves.toEqual({ ok: true, snapshot: { app: 'né' } })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('times out a wedged operation and starts a fresh helper for the next one', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { host, children } = createHost({ requestTimeoutMs: 30_000 })
|
||||
|
||||
const promise = host.request({ tool: 'click', app: 'Frozen' })
|
||||
await settle()
|
||||
await vi.advanceTimersByTimeAsync(30_001)
|
||||
|
||||
await expect(promise).rejects.toMatchObject({ code: 'action_timeout' })
|
||||
expect(children[0].killed).toBe(true)
|
||||
|
||||
const next = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(2)
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await expect(next).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('rejects the in-flight request when a working helper crashes, then restarts', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await first
|
||||
|
||||
const second = host.request({ tool: 'click', app: 'Notepad' })
|
||||
await settle()
|
||||
children[0].exit(1, 'boom')
|
||||
|
||||
await expect(second).rejects.toMatchObject({ code: 'accessibility_error' })
|
||||
await expect(second).rejects.toThrow(/runtime host exited/)
|
||||
|
||||
const third = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(2)
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await expect(third).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('shuts the helper down when idle and starts a new one on the next operation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { host, children } = createHost({ idleShutdownMs: 60_000 })
|
||||
|
||||
const first = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].respond({ ok: true, capabilities: {} })
|
||||
await first
|
||||
await settle()
|
||||
|
||||
expect(children[0].killed).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(60_001)
|
||||
expect(children[0].stdinEnded).toBe(true)
|
||||
expect(children[0].killed).toBe(true)
|
||||
|
||||
const next = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(2)
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await expect(next).resolves.toMatchObject({ ok: true })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('disposes the helper and rejects the in-flight request', async () => {
|
||||
const { host, children } = createHost()
|
||||
const promise = host.request({ tool: 'click', app: 'Notepad' })
|
||||
await settle()
|
||||
|
||||
host.dispose()
|
||||
|
||||
expect(children[0].stdinEnded).toBe(true)
|
||||
expect(children[0].killed).toBe(true)
|
||||
await expect(promise).rejects.toThrow(/shut down/)
|
||||
})
|
||||
|
||||
it('falls back to Bypass once when the execution policy blocks the start', async () => {
|
||||
const { host, children, specs } = createHost()
|
||||
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].exit(1, POLICY_ERROR)
|
||||
await settle()
|
||||
|
||||
expect(children).toHaveLength(2)
|
||||
expect(specs[1].args).toContain('Bypass')
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await expect(promise).resolves.toMatchObject({ ok: true })
|
||||
|
||||
// The fallback is remembered for the session rather than re-probed per call.
|
||||
const next = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
expect(children).toHaveLength(2)
|
||||
children[1].respond({ ok: true, capabilities: {} })
|
||||
await next
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('reports itself unavailable when Bypass is also refused', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].exit(1, POLICY_ERROR)
|
||||
await settle()
|
||||
children[1].exit(1, POLICY_ERROR)
|
||||
|
||||
await expect(promise).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
})
|
||||
|
||||
it('reports itself unavailable when the helper cannot be spawned at all', async () => {
|
||||
const host = new DesktopScriptRuntimeHost('C:\\orca\\runtime.ps1', {
|
||||
powerShellPath: () => 'C:\\Windows\\System32\\powershell.exe',
|
||||
warn: () => {},
|
||||
spawn: () => {
|
||||
throw new Error('spawn ENOENT')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(host.request({ tool: 'handshake' })).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
})
|
||||
|
||||
it('reports itself unavailable when a fresh helper dies before answering', async () => {
|
||||
const { host, children } = createHost()
|
||||
|
||||
const promise = host.request({ tool: 'handshake' })
|
||||
await settle()
|
||||
children[0].exit(1, 'The term is not recognized')
|
||||
|
||||
await expect(promise).rejects.toSatisfy(isRuntimeHostUnavailable)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,333 @@
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import type { ProcessSpec } from '../../shared/child-process/process-spec'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary'
|
||||
import type { BridgeRequest, BridgeResponse } from './desktop-script-provider-types'
|
||||
import { RuntimeClientError } from './runtime-client-error'
|
||||
import {
|
||||
FALLBACK_WINDOWS_EXECUTION_POLICY,
|
||||
PREFERRED_WINDOWS_EXECUTION_POLICY,
|
||||
isExecutionPolicyBlocked,
|
||||
windowsPowerShellRuntimeArgs,
|
||||
type WindowsExecutionPolicy
|
||||
} from './windows-powershell-execution-policy'
|
||||
|
||||
/** The all-pipes child `spawnProcess` returns; avoids a node:child_process import. */
|
||||
type RuntimeChildProcess = ReturnType<typeof spawnProcess>
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const IDLE_SHUTDOWN_MS = 120_000
|
||||
const MAX_RESPONSE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
/** Code the client keys on to fall back to the one-shot bridge for the session. */
|
||||
export const RUNTIME_HOST_UNAVAILABLE = 'runtime_host_unavailable'
|
||||
|
||||
export type DesktopScriptRuntimeHostOptions = {
|
||||
spawn?: (spec: ProcessSpec) => RuntimeChildProcess
|
||||
powerShellPath?: () => string
|
||||
requestTimeoutMs?: number
|
||||
idleShutdownMs?: number
|
||||
warn?: (message: string) => void
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: BridgeResponse) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
export function isRuntimeHostUnavailable(error: unknown): boolean {
|
||||
return error instanceof RuntimeClientError && error.code === RUNTIME_HOST_UNAVAILABLE
|
||||
}
|
||||
|
||||
/**
|
||||
* One long-lived `runtime.ps1 -Serve` process serving every computer-use
|
||||
* operation over NDJSON on stdin/stdout.
|
||||
*
|
||||
* Why persistent: the one-shot bridge started a powershell.exe per click, and
|
||||
* each one re-emitted the script's inline `Add-Type` P/Invoke assembly, which
|
||||
* Defender for Endpoint reports as suspicious MSIL emission alongside the
|
||||
* screen capture. Compiling once per session collapses a burst of short-lived
|
||||
* PIDs into a single process.
|
||||
*
|
||||
* Requests are strictly serialized: the protocol carries no request id because
|
||||
* only one operation is ever in flight, and native automation is not safe to
|
||||
* interleave anyway.
|
||||
*/
|
||||
export class DesktopScriptRuntimeHost {
|
||||
private child: RuntimeChildProcess | null = null
|
||||
private detachChild: (() => void) | null = null
|
||||
private decoder = new StringDecoder('utf8')
|
||||
private stdoutBuffer = ''
|
||||
private stderrText = ''
|
||||
private pending: PendingRequest | null = null
|
||||
private queueTail: Promise<void> | null = null
|
||||
private idleTimer: NodeJS.Timeout | null = null
|
||||
private policy: WindowsExecutionPolicy = PREFERRED_WINDOWS_EXECUTION_POLICY
|
||||
private policyRetryPending = false
|
||||
private childAnswered = false
|
||||
private unavailable = false
|
||||
private readonly requestTimeoutMs: number
|
||||
private readonly idleShutdownMs: number
|
||||
|
||||
constructor(
|
||||
private readonly scriptPath: string,
|
||||
private readonly options: DesktopScriptRuntimeHostOptions = {}
|
||||
) {
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS
|
||||
this.idleShutdownMs = options.idleShutdownMs ?? IDLE_SHUTDOWN_MS
|
||||
}
|
||||
|
||||
request(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
const run = (): Promise<BridgeResponse> => this.send(request)
|
||||
const result = this.queueTail ? this.queueTail.then(run, run) : run()
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
this.queueTail = tail
|
||||
void tail.finally(() => {
|
||||
if (this.queueTail !== tail) {
|
||||
return
|
||||
}
|
||||
this.queueTail = null
|
||||
this.armIdleTimer()
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/** Stop the helper. A later request starts a fresh one. */
|
||||
dispose(): void {
|
||||
this.clearIdleTimer()
|
||||
this.stopChild()
|
||||
this.rejectPending(
|
||||
new RuntimeClientError('accessibility_error', 'desktop provider runtime host was shut down')
|
||||
)
|
||||
}
|
||||
|
||||
private async send(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
this.clearIdleTimer()
|
||||
try {
|
||||
return await this.sendOnce(request)
|
||||
} catch (error) {
|
||||
if (!this.policyRetryPending) {
|
||||
throw error
|
||||
}
|
||||
this.policyRetryPending = false
|
||||
this.policy = FALLBACK_WINDOWS_EXECUTION_POLICY
|
||||
this.warn(
|
||||
`runtime host start blocked at ${PREFERRED_WINDOWS_EXECUTION_POLICY}; retrying once with ${FALLBACK_WINDOWS_EXECUTION_POLICY}`
|
||||
)
|
||||
return await this.sendOnce(request)
|
||||
}
|
||||
}
|
||||
|
||||
private sendOnce(request: BridgeRequest): Promise<BridgeResponse> {
|
||||
if (this.unavailable) {
|
||||
return Promise.reject(this.unavailableError('runtime host is unavailable'))
|
||||
}
|
||||
let child: RuntimeChildProcess
|
||||
try {
|
||||
child = this.ensureChild()
|
||||
} catch (error) {
|
||||
this.unavailable = true
|
||||
return Promise.reject(
|
||||
this.unavailableError(error instanceof Error ? error.message : String(error))
|
||||
)
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
// Why kill rather than wait: a hung UI Automation call cannot be
|
||||
// cancelled, so the process itself is the only thing left to reclaim.
|
||||
const timer = setTimeout(() => {
|
||||
this.abortChild(
|
||||
new RuntimeClientError(
|
||||
'action_timeout',
|
||||
`desktop provider timed out after ${this.requestTimeoutMs}ms`
|
||||
)
|
||||
)
|
||||
}, this.requestTimeoutMs)
|
||||
timer.unref?.()
|
||||
this.pending = { resolve, reject, timer }
|
||||
child.stdin.write(`${JSON.stringify(request)}\n`, (error) => {
|
||||
if (error) {
|
||||
this.abortChild(new RuntimeClientError('accessibility_error', error.message))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private ensureChild(): RuntimeChildProcess {
|
||||
if (this.child) {
|
||||
return this.child
|
||||
}
|
||||
const spawn = this.options.spawn ?? spawnProcess
|
||||
const child = spawn({
|
||||
program: (this.options.powerShellPath ?? windowsPowerShellPath)(),
|
||||
args: windowsPowerShellRuntimeArgs(this.scriptPath, this.policy, ['-Serve']),
|
||||
env: process.env
|
||||
})
|
||||
this.decoder = new StringDecoder('utf8')
|
||||
this.stdoutBuffer = ''
|
||||
this.stderrText = ''
|
||||
this.childAnswered = false
|
||||
|
||||
const onStdout = (chunk: Buffer | string): void => this.readStdout(chunk)
|
||||
const onStderr = (chunk: Buffer | string): void => {
|
||||
this.stderrText = `${this.stderrText}${chunk.toString()}`.slice(-4096)
|
||||
}
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null): void =>
|
||||
this.handleGone(child, signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`)
|
||||
const onError = (error: Error): void => this.handleGone(child, error.message)
|
||||
child.stdout.on('data', onStdout)
|
||||
child.stderr.on('data', onStderr)
|
||||
child.once('exit', onExit)
|
||||
child.once('error', onError)
|
||||
// An unhandled stream error is an uncaught exception in the main process.
|
||||
child.stdin.on('error', () => {})
|
||||
this.detachChild = (): void => {
|
||||
child.stdout.off('data', onStdout)
|
||||
child.stderr.off('data', onStderr)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
child.on('error', () => {})
|
||||
}
|
||||
this.child = child
|
||||
return child
|
||||
}
|
||||
|
||||
private readStdout(chunk: Buffer | string): void {
|
||||
this.stdoutBuffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk)
|
||||
if (this.stdoutBuffer.length > MAX_RESPONSE_BYTES) {
|
||||
this.abortChild(
|
||||
new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
'desktop provider response exceeded the runtime host buffer'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
for (let newline = this.stdoutBuffer.indexOf('\n'); newline >= 0;) {
|
||||
const line = this.stdoutBuffer.slice(0, newline).trim()
|
||||
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1)
|
||||
if (line) {
|
||||
this.deliver(line)
|
||||
}
|
||||
newline = this.stdoutBuffer.indexOf('\n')
|
||||
}
|
||||
}
|
||||
|
||||
private deliver(line: string): void {
|
||||
this.childAnswered = true
|
||||
const pending = this.takePending()
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
pending.resolve(JSON.parse(line) as BridgeResponse)
|
||||
} catch (error) {
|
||||
pending.reject(
|
||||
new RuntimeClientError(
|
||||
'accessibility_error',
|
||||
`desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private handleGone(child: RuntimeChildProcess, detail: string): void {
|
||||
// A replaced child can still report; that must not fail the live one.
|
||||
if (this.child !== child) {
|
||||
return
|
||||
}
|
||||
const text = [detail, this.stderrText.trim()].filter(Boolean).join(': ')
|
||||
const answered = this.childAnswered
|
||||
this.releaseChild()
|
||||
if (
|
||||
!answered &&
|
||||
this.policy === PREFERRED_WINDOWS_EXECUTION_POLICY &&
|
||||
isExecutionPolicyBlocked(text)
|
||||
) {
|
||||
this.policyRetryPending = true
|
||||
this.rejectPending(new RuntimeClientError('accessibility_error', text))
|
||||
return
|
||||
}
|
||||
if (!answered) {
|
||||
this.unavailable = true
|
||||
this.rejectPending(this.unavailableError(text))
|
||||
return
|
||||
}
|
||||
this.rejectPending(
|
||||
new RuntimeClientError('accessibility_error', `desktop provider runtime host exited: ${text}`)
|
||||
)
|
||||
}
|
||||
|
||||
private abortChild(error: Error): void {
|
||||
this.stopChild()
|
||||
this.rejectPending(error)
|
||||
}
|
||||
|
||||
private stopChild(): void {
|
||||
const child = this.child
|
||||
this.releaseChild()
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
// Closing stdin ends the serve loop; the kill covers a wedged helper.
|
||||
try {
|
||||
child.stdin.end()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
child.kill()
|
||||
}
|
||||
|
||||
private releaseChild(): void {
|
||||
const detach = this.detachChild
|
||||
this.detachChild = null
|
||||
detach?.()
|
||||
this.child = null
|
||||
}
|
||||
|
||||
private takePending(): PendingRequest | null {
|
||||
const pending = this.pending
|
||||
this.pending = null
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
private rejectPending(error: Error): void {
|
||||
this.takePending()?.reject(error)
|
||||
}
|
||||
|
||||
private armIdleTimer(): void {
|
||||
this.clearIdleTimer()
|
||||
if (!this.child) {
|
||||
return
|
||||
}
|
||||
this.idleTimer = setTimeout(() => {
|
||||
this.idleTimer = null
|
||||
this.stopChild()
|
||||
}, this.idleShutdownMs)
|
||||
this.idleTimer.unref?.()
|
||||
}
|
||||
|
||||
private clearIdleTimer(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer)
|
||||
this.idleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private unavailableError(message: string): RuntimeClientError {
|
||||
return new RuntimeClientError(
|
||||
RUNTIME_HOST_UNAVAILABLE,
|
||||
`desktop provider runtime host could not start: ${message}`
|
||||
)
|
||||
}
|
||||
|
||||
private warn(message: string): void {
|
||||
;(this.options.warn ?? ((text: string) => console.warn(`[computer-use] ${text}`)))(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { DesktopScriptRuntimeHost } from './desktop-script-runtime-host'
|
||||
|
||||
/**
|
||||
* The other half of the serve-mode proof: the unit test drives a fake child,
|
||||
* this one drives the real `runtime.ps1 -Serve` on a real Windows box.
|
||||
*
|
||||
* Both are needed. The framing that matters — one NDJSON line per response,
|
||||
* megabyte-scale screenshot payloads, a console writer that actually flushes —
|
||||
* only exists in PowerShell, and a fake child cannot disprove any of it.
|
||||
*
|
||||
* Runs only on win32; skipped elsewhere.
|
||||
*/
|
||||
const describeOnWindows = process.platform === 'win32' ? describe : describe.skip
|
||||
|
||||
const SCRIPT_PATH = resolve(__dirname, '../../../native/computer-use-windows/runtime.ps1')
|
||||
|
||||
describeOnWindows('runtime.ps1 serve mode', () => {
|
||||
let host: DesktopScriptRuntimeHost | null = null
|
||||
let spawns = 0
|
||||
|
||||
function startHost(): DesktopScriptRuntimeHost {
|
||||
spawns = 0
|
||||
host = new DesktopScriptRuntimeHost(SCRIPT_PATH, {
|
||||
warn: () => {},
|
||||
spawn: (spec) => {
|
||||
spawns++
|
||||
return spawnProcess(spec)
|
||||
}
|
||||
})
|
||||
return host
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
host?.dispose()
|
||||
host = null
|
||||
})
|
||||
|
||||
it('answers repeated operations from a single PowerShell process', async () => {
|
||||
const runtime = startHost()
|
||||
|
||||
await expect(runtime.request({ tool: 'handshake' })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
capabilities: { protocolVersion: 1, provider: 'orca-computer-use-windows' }
|
||||
})
|
||||
|
||||
const apps = await runtime.request({ tool: 'list_apps' })
|
||||
expect(apps.ok).toBe(true)
|
||||
expect(Array.isArray(apps.apps)).toBe(true)
|
||||
|
||||
await expect(runtime.request({ tool: 'handshake' })).resolves.toMatchObject({ ok: true })
|
||||
|
||||
expect(spawns).toBe(1)
|
||||
})
|
||||
|
||||
it('returns a structured error for a bad request without killing the helper', async () => {
|
||||
const runtime = startHost()
|
||||
|
||||
await expect(runtime.request({ tool: 'not_a_tool' })).resolves.toMatchObject({ ok: false })
|
||||
await expect(runtime.request({ tool: 'handshake' })).resolves.toMatchObject({ ok: true })
|
||||
expect(spawns).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Execution-policy handling for the Windows computer-use runtime script.
|
||||
*
|
||||
* Why not `Bypass` outright: it is the highest-weighted token on a
|
||||
* powershell.exe command line for Defender for Endpoint, and the shipped
|
||||
* runtime.ps1 does not need it — NSIS extraction writes no Zone.Identifier, so
|
||||
* an unsigned local script runs under `RemoteSigned`. `Restricted` is still the
|
||||
* Windows client default though, so a policy-blocked start must fall back once
|
||||
* rather than leaving computer use broken.
|
||||
*/
|
||||
export type WindowsExecutionPolicy = 'RemoteSigned' | 'Bypass'
|
||||
|
||||
export const PREFERRED_WINDOWS_EXECUTION_POLICY: WindowsExecutionPolicy = 'RemoteSigned'
|
||||
export const FALLBACK_WINDOWS_EXECUTION_POLICY: WindowsExecutionPolicy = 'Bypass'
|
||||
|
||||
// Matches the SecurityError PowerShell emits for `-File` under a blocking policy.
|
||||
const EXECUTION_POLICY_BLOCKED =
|
||||
/running scripts is disabled on this system|UnauthorizedAccess|PSSecurityException|SecurityError|about_Execution_Policies/i
|
||||
|
||||
export function isExecutionPolicyBlocked(text: string): boolean {
|
||||
return EXECUTION_POLICY_BLOCKED.test(text)
|
||||
}
|
||||
|
||||
export function windowsPowerShellRuntimeArgs(
|
||||
scriptPath: string,
|
||||
policy: WindowsExecutionPolicy,
|
||||
scriptArgs: readonly string[] = []
|
||||
): string[] {
|
||||
return [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
policy,
|
||||
'-File',
|
||||
scriptPath,
|
||||
...scriptArgs
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user