mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
Extract subprocess execution and process contracts into @orca/process-host with explicit public entry points, independent checks, and one Node 18 compatible CommonJS runtime. Migrate application consumers and integrate package builds, dependency selection, import enforcement, and runtime resource staging. Preserve daemon startup ownership and profile isolation, and qualify development rebuild recovery and concurrent output publication. Remove unnecessary aliases, test forwarding exports, and a duplicate output scan. Validation: full typechecks, changed-code quality, package and artifact checks, native Windows builds/types plus 306 runtime tests, and independent native reviews. Matched full E2E controls on original main and unchanged reviewed head both pass all 17 applicable tests (nine skips). Earlier intermittent E2E failures are documented in the PR; no assertions were weakened. The final combined-tree audit preserves both branches with no import-boundary violations.
858 lines
31 KiB
TypeScript
858 lines
31 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ProcessSpec } from '@orca/process-host/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<string, unknown>[] {
|
|
return this.writes.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
}
|
|
|
|
/** 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<string, unknown>, 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<void> {
|
|
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<void> {
|
|
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()
|
|
})
|
|
})
|