mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
* fix(computer-use): replace the macOS permission status gate with one 5s launch floor (#34390) Follows up #36028. The earlier gate grew background/explicit flags, epochs, backoff and a setup pgrep path that reviewers could not make correct. - Status: one probe at a time and one 5s launch floor shared by every reader, so every path stays at <=12 helper launches/min. Readers inside the floor join the running probe or ride one trailing probe that fires only if someone waits. Only grants are cached (until the floor expires); denials and errors are never served, so a grant shows within one floor locally and on web. No app epochs: without a denial cache they bought nothing. Off macOS the gate is skipped. - Setup: one launch per 5s across every permission, claimed synchronously; a request inside the window is queued (latest wins) and reported as launchQueued. Kill-and-relaunch stays so a click always re-shows the helper. Setup reuses the gate's in-flight/cached/last result instead of probing. - Reset checks helper availability from the bundle and invalidates after any tccutil attempt, including partial failures. - Remove the background flag from preload/IPC/renderer/web, the app-activity binding, and the sidecar permission_denied invalidation. * fix(computer-use): close the remaining gaps in the permission launch floor (#34390) - Clear the trailing timer whenever any probe launches and re-check the floor when it fires, so a late timer can never launch inside the floor. - Setup reuses a denial only until the floor expires. - A reader that joins a probe older than 300ms and gets a denial rides the next probe, so a grant shows within about one floor even after a stale web/RPC probe. - Setup requests are numbered on arrival, including ones that coalesce, so the latest request wins (A, B, A launches A); a queued launch already covered by a newer launch is dropped. Reset and will-quit cancel any queued launch. - The settings pane says "Opening…" and the CLI says queued for queued launches. * fix(computer-use): measure permission launch floors from real spawns and cancel setup on every host (#34390) - Both 5s floors use performance.now, so a wall-clock step cannot stretch or skip them. - The status floor counts from the later of claim and the actual `open` spawn, and the setup window from after its spawn, so a slow mkdtemp or pkill can't allow a 13th spawn in a 60s window. - Setup cancellation registers through AppEnvironment quit handlers (desktop, headless Electron serve, orcad) and orcad's shutdown trigger; reset and shutdown bump a generation so an Open still waiting on status never launches. - Reset cancels queued setup before its first await. - A setup request leaves the coalescing map when it launches, so a later request for the same permission queues and wins (latest-wins across microtasks). - A failed probe clears the remembered denial. * fix(computer-use): drop setup coalescing and gate setup on a host-wide shutdown flag (#34390) - Remove the per-permission setupInFlight map. Every Open goes through the 5s window and seq-ordered queue; a queued request with the same args that arrived before the last spawn was already shown by it, so concurrent same-permission Opens still spawn once. This fixes stale entries after reset turning the next Open into a no-op or a cancelled one. - A cancelled Open (reset or shutdown) rejects with an error instead of returning launchedHelper: false, which every client rendered as "setup complete". - Replace the per-host cancel calls with src/main/host-shutdown-state.ts, set synchronously by Electron will-quit and orcad's shutdown admission; setup checks it before every spawn, including the queued timer. - Track the status cache's expiry from the probe that produced it, so starting a new probe cannot revive an expired grant. - The queued setup timer re-checks its deadline and re-arms when early, and both timers round delays up; a throwing spawn there is logged, not uncaught. * fix(computer-use): keep setup latest-wins after slow probes and surface cancelled setups (#34390) - Record the launching request's seq; an older request that resumes or is queued after a newer one spawned is dropped, so latest-wins holds when requests resume out of order behind a probe. - orcad sets the host shutdown flag on every cleanup entry, including failed startup. - The web facade only falls back for hosts without computer.permissions; other failures, such as a cancelled Open, reject so the pane and onboarding show them. - The cancellation check runs before an untargeted Open can report everything granted. - Opens that arrive mid-reset wait for it, so reset's helper cleanup cannot kill what they open. - Queued wording now says a newer request or a reset may replace it. * fix(computer-use): wait out a reset before an Open reads permission status (#34390)
229 lines
7.9 KiB
TypeScript
229 lines
7.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { eraseRpcMethods } from '../core'
|
|
|
|
const computerMocks = vi.hoisted(() => ({
|
|
callComputerSidecarAction: vi.fn(),
|
|
callComputerSidecarCapabilities: vi.fn(),
|
|
callComputerSidecarListApps: vi.fn(),
|
|
callComputerSidecarListWindows: vi.fn(),
|
|
callComputerSidecarSnapshot: vi.fn(),
|
|
resetComputerSidecarForTest: vi.fn(),
|
|
openComputerUsePermissions: vi.fn(),
|
|
getComputerUsePermissionStatus: vi.fn()
|
|
}))
|
|
|
|
vi.mock('../../../computer/sidecar-client', () => ({
|
|
callComputerSidecarAction: computerMocks.callComputerSidecarAction,
|
|
callComputerSidecarCapabilities: computerMocks.callComputerSidecarCapabilities,
|
|
callComputerSidecarListApps: computerMocks.callComputerSidecarListApps,
|
|
callComputerSidecarListWindows: computerMocks.callComputerSidecarListWindows,
|
|
callComputerSidecarSnapshot: computerMocks.callComputerSidecarSnapshot,
|
|
resetComputerSidecarForTest: computerMocks.resetComputerSidecarForTest
|
|
}))
|
|
|
|
vi.mock('../../../computer/macos-computer-use-permissions', () => ({
|
|
openComputerUsePermissions: computerMocks.openComputerUsePermissions,
|
|
getComputerUsePermissionStatus: computerMocks.getComputerUsePermissionStatus
|
|
}))
|
|
|
|
import { COMPUTER_METHODS, resetComputerSessionsForTest } from './computer'
|
|
|
|
describe('computer RPC methods', () => {
|
|
beforeEach(() => {
|
|
computerMocks.callComputerSidecarAction.mockReset()
|
|
computerMocks.callComputerSidecarCapabilities.mockReset()
|
|
computerMocks.callComputerSidecarListApps.mockReset()
|
|
computerMocks.callComputerSidecarListWindows.mockReset()
|
|
computerMocks.callComputerSidecarSnapshot.mockReset()
|
|
computerMocks.resetComputerSidecarForTest.mockReset()
|
|
computerMocks.openComputerUsePermissions.mockReset()
|
|
computerMocks.getComputerUsePermissionStatus.mockReset()
|
|
resetComputerSessionsForTest()
|
|
computerMocks.resetComputerSidecarForTest.mockClear()
|
|
})
|
|
|
|
it('lists running apps through the sidecar', async () => {
|
|
const result = {
|
|
apps: [{ name: 'Finder', bundleId: 'com.apple.finder', pid: 100 }]
|
|
}
|
|
computerMocks.callComputerSidecarListApps.mockResolvedValue(result)
|
|
|
|
await expect(call('computer.listApps', {})).resolves.toBe(result)
|
|
expect(computerMocks.callComputerSidecarListApps).toHaveBeenCalledWith()
|
|
})
|
|
|
|
it('rejects ignored listApps scoping params', () => {
|
|
expect(() =>
|
|
findMethod('computer.listApps').params!.parse({ worktree: 'path:/tmp/repo' })
|
|
).toThrow()
|
|
})
|
|
|
|
it('returns provider capabilities through the sidecar', async () => {
|
|
const result = { platform: 'darwin', provider: 'orca-computer-use-macos', protocolVersion: 1 }
|
|
computerMocks.callComputerSidecarCapabilities.mockResolvedValue(result)
|
|
|
|
await expect(call('computer.capabilities', {})).resolves.toBe(result)
|
|
expect(computerMocks.callComputerSidecarCapabilities).toHaveBeenCalledWith()
|
|
})
|
|
|
|
it('opens computer-use permission setup', async () => {
|
|
const result = {
|
|
platform: 'darwin',
|
|
helperAppPath: '/Applications/Orca Computer Use.app',
|
|
openedSettings: false,
|
|
launchedHelper: true
|
|
}
|
|
computerMocks.openComputerUsePermissions.mockReturnValue(result)
|
|
|
|
await expect(call('computer.permissions', { id: 'accessibility' })).resolves.toBe(result)
|
|
expect(computerMocks.openComputerUsePermissions).toHaveBeenCalledWith('accessibility')
|
|
})
|
|
|
|
it('returns computer-use permission status', async () => {
|
|
const result = {
|
|
platform: 'darwin',
|
|
helperAppPath: '/Applications/Orca Computer Use.app',
|
|
helperUnavailableReason: null,
|
|
permissions: [{ id: 'accessibility', status: 'granted' }]
|
|
}
|
|
computerMocks.getComputerUsePermissionStatus.mockReturnValue(result)
|
|
|
|
await expect(call('computer.permissionsStatus', {})).resolves.toBe(result)
|
|
expect(computerMocks.getComputerUsePermissionStatus).toHaveBeenCalledWith(expect.any(Function))
|
|
})
|
|
|
|
it('lists windows through the sidecar', async () => {
|
|
const result = {
|
|
app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 },
|
|
windows: []
|
|
}
|
|
const params = { app: 'Finder' }
|
|
computerMocks.callComputerSidecarListWindows.mockResolvedValue(result)
|
|
|
|
await expect(call('computer.listWindows', params)).resolves.toBe(result)
|
|
expect(computerMocks.callComputerSidecarListWindows).toHaveBeenCalledWith(params)
|
|
})
|
|
|
|
it('rejects ignored listWindows scoping params', () => {
|
|
expect(() =>
|
|
findMethod('computer.listWindows').params!.parse({
|
|
app: 'Finder',
|
|
session: 'manual'
|
|
})
|
|
).toThrow()
|
|
expect(() =>
|
|
findMethod('computer.listWindows').params!.parse({
|
|
app: 'Finder',
|
|
worktree: 'path:/tmp/repo'
|
|
})
|
|
).toThrow()
|
|
})
|
|
|
|
it('gets app state through the sidecar', async () => {
|
|
const snapshot = {
|
|
snapshot: {
|
|
id: 'snap-test',
|
|
app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 },
|
|
window: { title: 'Finder', width: 100, height: 100 },
|
|
treeText: 'tree',
|
|
elementCount: 1,
|
|
focusedElementId: null
|
|
},
|
|
screenshot: null,
|
|
screenshotStatus: { state: 'skipped', reason: 'no_screenshot_flag' }
|
|
}
|
|
const params = {
|
|
app: 'Finder',
|
|
worktree: 'path:/tmp/repo',
|
|
noScreenshot: true,
|
|
restoreWindow: true,
|
|
windowId: 123
|
|
}
|
|
computerMocks.callComputerSidecarSnapshot.mockResolvedValue(snapshot)
|
|
|
|
await expect(call('computer.getAppState', params)).resolves.toBe(snapshot)
|
|
expect(computerMocks.callComputerSidecarSnapshot).toHaveBeenCalledWith(params)
|
|
})
|
|
|
|
it('rejects missing app in getAppState schema', () => {
|
|
const method = findMethod('computer.getAppState')
|
|
expect(() => method.params!.parse({})).toThrow()
|
|
})
|
|
|
|
it('rejects ambiguous window targeting', () => {
|
|
expect(() =>
|
|
findMethod('computer.getAppState').params!.parse({
|
|
app: 'Finder',
|
|
windowId: 1,
|
|
windowIndex: 0
|
|
})
|
|
).toThrow(/either --window-id or --window-index/)
|
|
expect(() =>
|
|
findMethod('computer.click').params!.parse({
|
|
app: 'Finder',
|
|
elementIndex: 0,
|
|
windowId: 1,
|
|
windowIndex: 0
|
|
})
|
|
).toThrow(/either --window-id or --window-index/)
|
|
})
|
|
|
|
it('rejects ambiguous session and worktree targeting', () => {
|
|
expect(() =>
|
|
findMethod('computer.getAppState').params!.parse({
|
|
app: 'Finder',
|
|
session: 'manual',
|
|
worktree: 'id:repo::/tmp/repo'
|
|
})
|
|
).toThrow(/either session or worktree/)
|
|
expect(() =>
|
|
findMethod('computer.click').params!.parse({
|
|
app: 'Finder',
|
|
session: 'manual',
|
|
worktree: 'id:repo::/tmp/repo',
|
|
elementIndex: 0
|
|
})
|
|
).toThrow(/either session or worktree/)
|
|
})
|
|
|
|
it('treats empty computer-use worktree scope as absent', () => {
|
|
expect(
|
|
findMethod('computer.getAppState').params!.parse({
|
|
app: 'Finder',
|
|
worktree: ''
|
|
})
|
|
).toMatchObject({
|
|
app: 'Finder',
|
|
worktree: undefined
|
|
})
|
|
})
|
|
|
|
it('rejects malformed hotkey specs before dispatch', () => {
|
|
expect(() =>
|
|
findMethod('computer.hotkey').params!.parse({ app: 'Finder', key: 'Return' })
|
|
).toThrow(/Hotkey requires a modifier and one key/)
|
|
expect(() =>
|
|
findMethod('computer.hotkey').params!.parse({ app: 'Finder', key: 'CmdOrCtrl+Shift' })
|
|
).toThrow(/Hotkey requires a modifier and one key/)
|
|
expect(() =>
|
|
findMethod('computer.hotkey').params!.parse({ app: 'Finder', key: 'Ctrl+A+B' })
|
|
).toThrow(/Hotkey requires a modifier and one key/)
|
|
})
|
|
})
|
|
|
|
function findMethod(name: string) {
|
|
const method = eraseRpcMethods(COMPUTER_METHODS).find((candidate) => candidate.name === name)
|
|
if (!method) {
|
|
throw new Error(`missing method ${name}`)
|
|
}
|
|
return method
|
|
}
|
|
|
|
async function call(name: string, params: Record<string, unknown>) {
|
|
const method = findMethod(name)
|
|
const parsed = method.params ? method.params.parse(params) : undefined
|
|
return await method.handler(parsed, {
|
|
runtime: { getRuntimeId: () => 'runtime-1' } as never
|
|
})
|
|
}
|