fix(computer-use): cap every macOS permission helper launch path at one per 5 s (#34390) (#36150)

* 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)
This commit is contained in:
Neil
2026-10-29 16:33:52 -07:00
committed by GitHub
parent 3fcac72437
commit b187d55e15
24 changed files with 985 additions and 424 deletions
+14 -3
View File
@@ -50,6 +50,7 @@ export const COMPUTER_HANDLERS: Record<string, CommandHandler> = {
helperAppPath: string | null
openedSettings: boolean
launchedHelper: boolean
launchQueued?: boolean
permissions?: { id: string; status: string }[]
nextStep?: string | null
}>('computer.permissions', id ? { id } : {})
@@ -57,9 +58,7 @@ export const COMPUTER_HANDLERS: Record<string, CommandHandler> = {
if (value.platform !== 'darwin') {
return 'Computer-use permission setup is only required on macOS.'
}
const firstLine = value.launchedHelper
? 'Opened Orca Computer Use permission setup.'
: 'Computer Use permissions checked.'
const firstLine = permissionSetupHeadline(value)
return [
firstLine,
`Helper app: ${value.helperAppPath}`,
@@ -251,3 +250,15 @@ function formatComputerCapabilities(value: ComputerProviderCapabilities): string
...(value.supports.input ? [` Input: background=${value.supports.input.background}`] : [])
].join('\n')
}
function permissionSetupHeadline(result: {
launchedHelper: boolean
launchQueued?: boolean
}): string {
if (result.launchQueued) {
return 'Queued Orca Computer Use permission setup; a newer request or a reset may replace it.'
}
return result.launchedHelper
? 'Opened Orca Computer Use permission setup.'
: 'Computer Use permissions checked.'
}
@@ -1,13 +1,19 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
ComputerUsePermissionStatus,
ComputerUsePermissionStatusResult
} from '../../shared/computer-use-permissions-types'
import {
bindPermissionStatusGateToAppActivity,
createPermissionStatusProbeGate
computerUsePermissionStatusGate,
createPermissionStatusGate
} from './macos-computer-use-permission-status-gate'
const getStatusMock = vi.hoisted(() => vi.fn())
vi.mock('./macos-computer-use-permissions', () => ({
getComputerUsePermissionStatus: getStatusMock
}))
function statusWith(accessibility: ComputerUsePermissionStatus): ComputerUsePermissionStatusResult {
return {
platform: 'darwin',
@@ -22,148 +28,240 @@ function statusWith(accessibility: ComputerUsePermissionStatus): ComputerUsePerm
const denied = statusWith('not-granted')
const granted = statusWith('granted')
const background = { background: true }
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((settle) => {
resolve = settle
})
return { promise, resolve }
// Most spawns inside any half-open window [t, t + windowMs).
function maxSpawnsInAnyWindow(spawnedAt: number[], windowMs: number): number {
return Math.max(
0,
...spawnedAt.map(
(start) => spawnedAt.filter((at) => at >= start && at < start + windowMs).length
)
)
}
describe('createPermissionStatusProbeGate', () => {
it('stops relaunching the helper for passive readers while Accessibility stays denied (#34390)', async () => {
let clock = 0
const probe = vi.fn(async () => denied)
const gate = createPermissionStatusProbeGate({ probe, now: () => clock })
// Three always-mounted setup-guide hooks read on every window focus.
await Promise.all([gate.read(background), gate.read(background), gate.read(background)])
expect(probe).toHaveBeenCalledTimes(1)
for (let elapsed = 0; elapsed < 600_000; elapsed += 500) {
clock += 500
await gate.read(background)
}
// Backoff 10s, 20s, 40s, 80s, then 120s cap: 8 launches in 10 minutes instead of 1,200.
expect(probe).toHaveBeenCalledTimes(8)
gate.noteAppReturn()
await gate.read(background)
expect(probe).toHaveBeenCalledTimes(8)
describe('createPermissionStatusGate', () => {
beforeEach(() => {
vi.useFakeTimers({ now: 0 })
})
it('re-probes once per return to the app so a grant or revocation shows immediately', async () => {
const probe = vi.fn().mockResolvedValueOnce(denied).mockResolvedValueOnce(granted)
const gate = createPermissionStatusProbeGate({ probe, now: () => 0 })
afterEach(() => {
vi.useRealTimers()
})
await expect(gate.read(background)).resolves.toBe(denied)
gate.noteAppDeparture()
gate.noteAppReturn()
const reads = await Promise.all([gate.read(background), gate.read(background)])
expect(reads).toEqual([granted, granted])
expect(probe).toHaveBeenCalledTimes(2)
it('shares one launch between concurrent readers', async () => {
const probe = vi.fn(async () => denied)
const gate = createPermissionStatusGate(probe)
probe.mockResolvedValueOnce(denied)
gate.noteAppDeparture()
gate.noteAppReturn()
await expect(gate.read(background)).resolves.toBe(denied)
await Promise.all([gate.read(), gate.read(), gate.read()])
expect(probe).toHaveBeenCalledTimes(1)
})
it('allows at most 12 real spawns in any 60s window, even with a slow pre-spawn step', async () => {
const spawnedAt: number[] = []
let preSpawnDelayMs = 100
const probe = vi.fn(async (markSpawned: () => void) => {
// ast-grep-ignore: test-no-real-time-wait -- fake timers drive this simulated mkdtemp delay
await new Promise((resolve) => setTimeout(resolve, preSpawnDelayMs))
preSpawnDelayMs = 0
markSpawned()
spawnedAt.push(performance.now())
return denied
})
const gate = createPermissionStatusGate(probe)
// Fire-and-forget focus reads every 500ms, then a caller that re-reads as each answer lands.
for (let elapsed = 0; elapsed < 120_000; elapsed += 500) {
void gate.read()
await vi.advanceTimersByTimeAsync(500)
}
const loopEnd = performance.now() + 120_000
while (performance.now() < loopEnd) {
let answered = false
void gate.read().then(() => (answered = true))
while (!answered) {
await vi.advanceTimersByTimeAsync(100)
}
}
expect(maxSpawnsInAnyWindow(spawnedAt, 60_000)).toBeLessThanOrEqual(12)
})
it('never serves a denial or error from cache, so a grant shows within one floor', async () => {
const failure = new Error('Timed out checking permissions')
const probe = vi
.fn()
.mockResolvedValueOnce(denied)
.mockRejectedValueOnce(failure)
.mockResolvedValueOnce(granted)
const gate = createPermissionStatusGate(probe)
await expect(gate.read()).resolves.toBe(denied)
await vi.advanceTimersByTimeAsync(1_000)
const afterDenial = expect(gate.read()).rejects.toBe(failure)
await vi.advanceTimersByTimeAsync(4_000)
await afterDenial
await vi.advanceTimersByTimeAsync(1_000)
const afterError = gate.read()
await vi.advanceTimersByTimeAsync(4_000)
await expect(afterError).resolves.toBe(granted)
expect(probe).toHaveBeenCalledTimes(3)
})
it('serves a fresh read when the renderer focus read beats did-become-active', async () => {
let active = false
const probe = vi.fn().mockResolvedValueOnce(denied).mockResolvedValueOnce(granted)
const gate = createPermissionStatusProbeGate({ probe, now: () => 0 })
gate.setAppActiveProbe(() => active)
await gate.read(background)
gate.noteAppDeparture()
await expect(gate.read(background)).resolves.toBe(denied)
expect(probe).toHaveBeenCalledTimes(1)
active = true
await expect(gate.read(background)).resolves.toBe(granted)
gate.noteAppReturn()
await expect(gate.read(background)).resolves.toBe(granted)
expect(probe).toHaveBeenCalledTimes(2)
})
it('counts the first app return after a startup spent inactive', async () => {
const probe = vi.fn().mockResolvedValueOnce(denied).mockResolvedValueOnce(granted)
const gate = createPermissionStatusProbeGate({ probe, now: () => 0 })
let returnToApp = (): void => {}
let active = false
bindPermissionStatusGateToAppActivity(gate, {
onDeparture: () => {},
onReturn: (listener) => {
returnToApp = listener
},
isAppActive: () => active
})
await gate.read(background)
active = true
returnToApp()
await expect(gate.read(background)).resolves.toBe(granted)
})
it('never serves a finished denial to foreground readers, even on a quick return', async () => {
let clock = 0
const probe = vi.fn().mockResolvedValueOnce(denied).mockResolvedValueOnce(granted)
const gate = createPermissionStatusProbeGate({ probe, now: () => clock })
it('serves a grant until the floor expires, then re-checks immediately', async () => {
const probe = vi.fn(async () => granted)
const gate = createPermissionStatusGate(probe)
await gate.read()
// The user grants at 250ms and the browser returns at 500ms.
clock += 500
await expect(gate.read(background)).resolves.toBe(denied)
const reads = await Promise.all([gate.read(), gate.read(), gate.read()])
expect(reads).toEqual([granted, granted, granted])
expect(probe).toHaveBeenCalledTimes(2)
clock += 2_000 - 1
await expect(gate.read()).resolves.toBe(granted)
await vi.advanceTimersByTimeAsync(4_999)
await gate.read()
expect(probe).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
await gate.read()
expect(probe).toHaveBeenCalledTimes(2)
})
it('discards an invalidated in-flight probe without overlapping helpers', async () => {
let clock = 0
const first = deferred<ComputerUsePermissionStatusResult>()
const probe = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue(denied)
const gate = createPermissionStatusProbeGate({ probe, now: () => clock })
it('does not revive an expired grant when a new probe starts', async () => {
let finishSecond: (result: ComputerUsePermissionStatusResult) => void = () => {}
const probe = vi
.fn()
.mockResolvedValueOnce(granted)
.mockReturnValueOnce(
new Promise<ComputerUsePermissionStatusResult>((resolve) => {
finishSecond = resolve
})
)
const gate = createPermissionStatusGate(probe)
const obsolete = gate.read(background)
await gate.read()
await vi.advanceTimersByTimeAsync(5_000)
void gate.read()
const joined = gate.read()
finishSecond(denied)
await expect(joined).resolves.toBe(denied)
})
it('launches no trailing probe without a waiter', async () => {
const probe = vi.fn(async () => denied)
const gate = createPermissionStatusGate(probe)
await gate.read()
await vi.advanceTimersByTimeAsync(60_000)
expect(probe).toHaveBeenCalledTimes(1)
})
it('keeps readers off a probe that started before an invalidation, without overlapping helpers', async () => {
let finishFirst: (result: ComputerUsePermissionStatusResult) => void = () => {}
const probe = vi
.fn()
.mockReturnValueOnce(
new Promise<ComputerUsePermissionStatusResult>((resolve) => {
finishFirst = resolve
})
)
.mockResolvedValue(denied)
const gate = createPermissionStatusGate(probe)
void gate.read()
gate.invalidate()
const current = gate.read(background)
const afterReset = gate.read()
await vi.advanceTimersByTimeAsync(10_000)
expect(probe).toHaveBeenCalledTimes(1)
first.resolve(denied)
await expect(obsolete).resolves.toBe(denied)
await expect(current).resolves.toBe(denied)
finishFirst(granted)
await vi.advanceTimersByTimeAsync(0)
await expect(afterReset).resolves.toBe(denied)
expect(probe).toHaveBeenCalledTimes(2)
})
// Only the live probe counts toward the backoff: first step is 10s, not 20s.
clock += 10_000
await gate.read(background)
it('never lets a stale trailing timer launch inside the floor', async () => {
const probe = vi.fn(async () => denied)
const gate = createPermissionStatusGate(probe)
await gate.read()
await vi.advanceTimersByTimeAsync(1_000)
void gate.read()
// The trailing timer runs late: a reader that sees the floor expired launches first.
const lateClock = vi.spyOn(performance, 'now').mockReturnValue(5_001)
await gate.read()
await vi.advanceTimersByTimeAsync(0)
lateClock.mockRestore()
void gate.read()
expect(probe).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(8_999)
expect(probe).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(100)
expect(probe).toHaveBeenCalledTimes(3)
})
it('shares a failed check briefly instead of backing it off like a denial', async () => {
let clock = 0
const failure = new Error('Timed out checking permissions')
const probe = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(granted)
const gate = createPermissionStatusProbeGate({ probe, now: () => clock })
it('stops reusing a denial for setup once the floor expires', async () => {
const probe = vi.fn().mockResolvedValueOnce(denied).mockResolvedValueOnce(granted)
const gate = createPermissionStatusGate(probe)
const results = await Promise.allSettled([gate.read(background), gate.read(background)])
expect(results).toEqual([
{ status: 'rejected', reason: failure },
{ status: 'rejected', reason: failure }
])
expect(probe).toHaveBeenCalledTimes(1)
clock += 2_000
await expect(gate.read(background)).resolves.toBe(granted)
await gate.read()
await expect(gate.peek()).resolves.toBe(denied)
await vi.advanceTimersByTimeAsync(5_000)
await expect(gate.peek()).resolves.toBe(granted)
expect(probe).toHaveBeenCalledTimes(2)
})
it('sends a late joiner that gets a denial to the next probe', async () => {
let finishFirst: (result: ComputerUsePermissionStatusResult) => void = () => {}
const probe = vi
.fn()
.mockReturnValueOnce(
new Promise<ComputerUsePermissionStatusResult>((resolve) => {
finishFirst = resolve
})
)
.mockResolvedValueOnce(granted)
const gate = createPermissionStatusGate(probe)
// A web read starts a probe; the user grants and returns to Orca a second later.
void gate.read()
await vi.advanceTimersByTimeAsync(1_000)
const onReturn = gate.read()
finishFirst(denied)
await vi.advanceTimersByTimeAsync(4_000)
await expect(onReturn).resolves.toBe(granted)
expect(probe).toHaveBeenCalledTimes(2)
})
it('does not cache a grant from a probe that started before an invalidation', async () => {
let finishFirst: (result: ComputerUsePermissionStatusResult) => void = () => {}
const probe = vi
.fn()
.mockReturnValueOnce(
new Promise<ComputerUsePermissionStatusResult>((resolve) => {
finishFirst = resolve
})
)
.mockResolvedValue(denied)
const gate = createPermissionStatusGate(probe)
void gate.read()
gate.invalidate()
finishFirst(granted)
await vi.advanceTimersByTimeAsync(1_000)
const afterReset = gate.read()
await vi.advanceTimersByTimeAsync(4_000)
await expect(afterReset).resolves.toBe(denied)
})
})
describe('computerUsePermissionStatusGate', () => {
const originalPlatform = process.platform
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
it('never waits off macOS, where the probe launches no helper', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
getStatusMock.mockResolvedValue(statusWith('unsupported'))
await computerUsePermissionStatusGate.read()
await computerUsePermissionStatusGate.read()
await computerUsePermissionStatusGate.read()
expect(getStatusMock).toHaveBeenCalledTimes(3)
})
})
@@ -1,186 +1,166 @@
import type { ComputerUsePermissionStatusResult } from '../../shared/computer-use-permissions-types'
// Each macOS probe launches a helper that polls TCC for ~3.5s; denied answers only change on user action.
const PERMISSION_STATUS_RESULT_REUSE_MS = 2_000
const PERMISSION_STATUS_DENIED_MIN_BACKOFF_MS = 10_000
const PERMISSION_STATUS_DENIED_MAX_BACKOFF_MS = 120_000
// Each macOS probe launches a helper that settles TCC for ~2s; 5s between real spawns allows 12 per 60s window.
// performance.now is monotonic, so a wall-clock step can't stretch or skip the floor.
const PERMISSION_STATUS_LAUNCH_FLOOR_MS = 5_000
// A probe older than this may predate a grant, so a denial from it sends the reader to the next probe.
const PERMISSION_STATUS_FRESH_JOIN_MS = 300
type ProbeOutcome =
| { ok: true; result: ComputerUsePermissionStatusResult }
| { ok: false; error: unknown }
type StatusResult = ComputerUsePermissionStatusResult
type InFlightProbe = {
epoch: number
promise: Promise<ComputerUsePermissionStatusResult>
followUp: Promise<ComputerUsePermissionStatusResult> | null
}
export type PermissionStatusReadOptions = {
// Local passive badges opt into the denied backoff; foreground reads never get a cached denial.
background?: boolean
}
export type PermissionStatusProbeGate = {
read: (options?: PermissionStatusReadOptions) => Promise<ComputerUsePermissionStatusResult>
noteAppDeparture: () => void
noteAppReturn: () => void
setAppActiveProbe: (isAppActive: () => boolean) => void
export type PermissionStatusGate = {
read: () => Promise<StatusResult>
// Setup's cheapest answer: in-flight, recent grant or last denial, before launching anything new.
peek: () => Promise<StatusResult>
invalidate: () => void
}
export type AppActivitySource = {
onDeparture: (listener: () => void) => void
onReturn: (listener: () => void) => void
isAppActive: () => boolean
}
// probe calls markSpawned when its helper actually starts; the floor counts from the later of claim and spawn.
export function createPermissionStatusGate(
probe: (markSpawned: () => void) => Promise<StatusResult>
): PermissionStatusGate {
let inFlight: { promise: Promise<StatusResult>; joinable: boolean } | null = null
let lastLaunchAt = Number.NEGATIVE_INFINITY
// Only grants are cached; a denial or error is never served, so a fresh grant shows within one floor.
let granted: StatusResult | null = null
// Reuse ends one floor after the probe that produced the answer, not after whichever probe ran last.
let cachedUntil = Number.NEGATIVE_INFINITY
// Setup may reuse a denial until the floor expires, so it never acts on one older than a floor.
let lastDenied: StatusResult | null = null
let waiters: {
promise: Promise<StatusResult>
resolve: (next: Promise<StatusResult>) => void
} | null = null
let trailingTimer: ReturnType<typeof setTimeout> | null = null
export function createPermissionStatusProbeGate(options: {
probe: () => Promise<ComputerUsePermissionStatusResult>
now?: () => number
}): PermissionStatusProbeGate {
const now = options.now ?? Date.now
// A new epoch means the answer may have changed; results from older epochs are never served.
let epoch = 0
let departed = false
let isAppActive = (): boolean => false
let inFlight: InFlightProbe | null = null
let cached: { epoch: number; outcome: ProbeOutcome; finishedAt: number } | null = null
let reuseUntil = 0
let deniedStreak = 0
const floorExpiresAt = (): number => lastLaunchAt + PERMISSION_STATUS_LAUNCH_FLOOR_MS
const noteAppReturn = (): void => {
if (departed) {
departed = false
epoch += 1
}
}
const recordOutcome = (probeEpoch: number, outcome: ProbeOutcome): void => {
if (probeEpoch !== epoch) {
const armTrailing = (): void => {
if (!waiters || inFlight || trailingTimer) {
return
}
const finishedAt = now()
cached = { epoch: probeEpoch, outcome, finishedAt }
if (!outcome.ok || !isMissingPermission(outcome.result)) {
if (outcome.ok) {
deniedStreak = 0
}
reuseUntil = finishedAt + PERMISSION_STATUS_RESULT_REUSE_MS
return
}
deniedStreak += 1
const backoff = PERMISSION_STATUS_DENIED_MIN_BACKOFF_MS * 2 ** (deniedStreak - 1)
reuseUntil = finishedAt + Math.min(backoff, PERMISSION_STATUS_DENIED_MAX_BACKOFF_MS)
}
const startProbe = (): Promise<ComputerUsePermissionStatusResult> => {
const probeEpoch = epoch
const entry: InFlightProbe = {
epoch: probeEpoch,
promise: options.probe().then(
(result) => {
recordOutcome(probeEpoch, { ok: true, result })
return result
},
(error: unknown) => {
recordOutcome(probeEpoch, { ok: false, error })
throw error
trailingTimer = setTimeout(
() => {
trailingTimer = null
if (waiters && !inFlight && performance.now() >= floorExpiresAt()) {
void launch().catch(() => {})
} else {
armTrailing()
}
),
followUp: null
},
Math.max(0, Math.ceil(floorExpiresAt() - performance.now()))
)
trailingTimer.unref?.()
}
const launch = (): Promise<StatusResult> => {
lastLaunchAt = performance.now()
if (trailingTimer) {
clearTimeout(trailingTimer)
trailingTimer = null
}
const entry = {
promise: probe(() => {
lastLaunchAt = Math.max(lastLaunchAt, performance.now())
}),
joinable: true
}
inFlight = entry
void entry.promise
entry.promise
.then(
(result) => {
if (entry.joinable) {
granted = isCacheable(result) ? result : null
lastDenied = granted ? null : result
cachedUntil = floorExpiresAt()
}
},
() => {
granted = null
lastDenied = null
}
)
.finally(() => {
if (inFlight === entry) {
inFlight = null
}
armTrailing()
})
.catch(() => {})
// Readers waiting out the floor take whichever probe starts next, whatever triggered it.
const pending = waiters
waiters = null
pending?.resolve(entry.promise)
return entry.promise
}
const reuseLimit = (
entry: { outcome: ProbeOutcome; finishedAt: number },
readOptions: PermissionStatusReadOptions
): number => {
if (readOptions.background) {
return reuseUntil
const waitForNextProbe = (): Promise<StatusResult> => {
if (!waiters) {
let resolve: (next: Promise<StatusResult>) => void = () => {}
const promise = new Promise<StatusResult>((settle) => {
resolve = settle
})
waiters = { promise, resolve }
}
// Foreground readers may have just granted; only passive badges accept a finished denial.
if (entry.outcome.ok && isMissingPermission(entry.outcome.result)) {
return 0
}
return entry.finishedAt + PERMISSION_STATUS_RESULT_REUSE_MS
armTrailing()
return waiters.promise
}
const read = (
readOptions: PermissionStatusReadOptions = {}
): Promise<ComputerUsePermissionStatusResult> => {
// The renderer's focus read can reach main before did-become-active does.
if (departed && isAppActive()) {
noteAppReturn()
const read = (): Promise<StatusResult> => {
if (granted && performance.now() < cachedUntil) {
return Promise.resolve(granted)
}
const current = inFlight
if (current) {
if (current.epoch === epoch) {
return current.promise
if (inFlight?.joinable) {
const joined = inFlight.promise
if (performance.now() - lastLaunchAt <= PERMISSION_STATUS_FRESH_JOIN_MS) {
return joined
}
// Never run two helpers at once; re-probe once the stale one settles.
current.followUp ??= current.promise.then(noop, noop).then(() => read(readOptions))
return current.followUp
return joined.then((result) => (isCacheable(result) ? result : waitForNextProbe()))
}
if (cached && cached.epoch === epoch) {
if (now() < reuseLimit(cached, readOptions)) {
const { outcome } = cached
return outcome.ok ? Promise.resolve(outcome.result) : Promise.reject(outcome.error)
}
if (!inFlight && performance.now() >= floorExpiresAt()) {
return launch()
}
return startProbe()
return waitForNextProbe()
}
return {
read,
noteAppDeparture: () => {
departed = true
},
noteAppReturn,
setAppActiveProbe: (probe) => {
isAppActive = probe
peek: () => {
if (inFlight?.joinable) {
return inFlight.promise
}
if (granted && performance.now() < cachedUntil) {
return Promise.resolve(granted)
}
return lastDenied && performance.now() < cachedUntil ? Promise.resolve(lastDenied) : read()
},
invalidate: () => {
epoch += 1
deniedStreak = 0
granted = null
lastDenied = null
if (inFlight) {
inFlight.joinable = false
}
}
}
}
// App-level activation only: moving between Orca's own windows can't change a TCC answer.
export function bindPermissionStatusGateToAppActivity(
gate: PermissionStatusProbeGate,
activity: AppActivitySource
): void {
gate.setAppActiveProbe(activity.isAppActive)
// Listeners attach after startup; a grant made while Orca was inactive must count on first return.
if (!activity.isAppActive()) {
gate.noteAppDeparture()
}
activity.onDeparture(() => gate.noteAppDeparture())
activity.onReturn(() => gate.noteAppReturn())
}
function noop(): void {}
function isMissingPermission(result: ComputerUsePermissionStatusResult): boolean {
// A missing helper answers without launching anything, so it is as safe to reuse as a grant.
function isCacheable(result: StatusResult): boolean {
return (
result.helperUnavailableReason === null &&
result.permissions.some((permission) => permission.status === 'not-granted')
result.helperUnavailableReason !== null ||
result.permissions.every((permission) => permission.status !== 'not-granted')
)
}
export const computerUsePermissionStatusGate = createPermissionStatusProbeGate({
probe: async () => {
const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
return getComputerUsePermissionStatus()
}
})
async function probeStatus(markSpawned: () => void = () => {}): Promise<StatusResult> {
const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
return getComputerUsePermissionStatus(markSpawned)
}
const macosGate = createPermissionStatusGate(probeStatus)
// Off macOS the probe answers 'unsupported' instantly without a helper, so it skips the gate.
export const computerUsePermissionStatusGate: PermissionStatusGate = {
read: () => (process.platform === 'darwin' ? macosGate.read() : probeStatus()),
peek: () => (process.platform === 'darwin' ? macosGate.peek() : probeStatus()),
invalidate: () => macosGate.invalidate()
}
@@ -17,11 +17,16 @@ import { stopMacOSPermissionStatusHelper } from './macos-computer-use-permission
const PERMISSION_STATUS_HELPER_LAUNCH_TIMEOUT_MS = 5_000
export function getComputerUsePermissionStatus(): Promise<ComputerUsePermissionStatusResult> {
return getComputerUsePermissionStatusAsync()
// onHelperSpawn fires when `open` actually starts, so launch budgets measure real spawns.
export function getComputerUsePermissionStatus(
onHelperSpawn: () => void = () => {}
): Promise<ComputerUsePermissionStatusResult> {
return getComputerUsePermissionStatusAsync(onHelperSpawn)
}
async function getComputerUsePermissionStatusAsync(): Promise<ComputerUsePermissionStatusResult> {
async function getComputerUsePermissionStatusAsync(
onHelperSpawn: () => void
): Promise<ComputerUsePermissionStatusResult> {
if (process.platform !== 'darwin') {
return {
platform: process.platform,
@@ -35,19 +40,15 @@ async function getComputerUsePermissionStatusAsync(): Promise<ComputerUsePermiss
}
const helperAppPath = resolveMacOSComputerUseAppPath()
if (!helperAppPath) {
return createUnavailablePermissionStatus('Orca Computer Use.app was not found', null)
}
const executablePath = resolveMacOSComputerUseExecutablePath()
if (!executablePath) {
const unavailableReason = resolveComputerUseHelperUnavailableReason()
if (!helperAppPath || unavailableReason) {
return createUnavailablePermissionStatus(
`${helperAppPath}/Contents/MacOS/orca-computer-use-macos was not found`,
unavailableReason ?? 'Orca Computer Use.app was not found',
helperAppPath
)
}
const raw = await readPermissionStatusFromHelperApp(helperAppPath)
const raw = await readPermissionStatusFromHelperApp(helperAppPath, onHelperSpawn)
return {
platform: process.platform,
@@ -60,6 +61,18 @@ async function getComputerUsePermissionStatusAsync(): Promise<ComputerUsePermiss
}
}
// Answers from the bundle layout alone, without launching the helper.
export function resolveComputerUseHelperUnavailableReason(): string | null {
const helperAppPath = resolveMacOSComputerUseAppPath()
if (!helperAppPath) {
return 'Orca Computer Use.app was not found'
}
if (!resolveMacOSComputerUseExecutablePath()) {
return `${helperAppPath}/Contents/MacOS/orca-computer-use-macos was not found`
}
return null
}
function createUnavailablePermissionStatus(
reason: string,
helperAppPath: string | null
@@ -76,13 +89,14 @@ function createUnavailablePermissionStatus(
}
async function readPermissionStatusFromHelperApp(
helperAppPath: string
helperAppPath: string,
onHelperSpawn: () => void
): Promise<Partial<Record<ComputerUsePermissionId, ComputerUsePermissionStatus>>> {
const tempDir = await mkdtemp(join(tmpdir(), 'orca-computer-use-permissions-'))
const statusPath = join(tempDir, 'status.json')
try {
// Launch the helper app so TCC checks its identity instead of inheriting the parent's grants.
await launchPermissionStatusHelper(helperAppPath, statusPath)
await launchPermissionStatusHelper(helperAppPath, statusPath, onHelperSpawn)
for (let attempt = 0; attempt < 50; attempt++) {
if (await fileExists(statusPath)) {
@@ -100,7 +114,11 @@ async function readPermissionStatusFromHelperApp(
}
}
function launchPermissionStatusHelper(helperAppPath: string, statusPath: string): Promise<void> {
function launchPermissionStatusHelper(
helperAppPath: string,
statusPath: string,
onHelperSpawn: () => void
): Promise<void> {
return new Promise((resolve, reject) => {
const launch = spawn(
'/usr/bin/open',
@@ -109,6 +127,7 @@ function launchPermissionStatusHelper(helperAppPath: string, statusPath: string)
stdio: ['ignore', 'pipe', 'pipe']
}
)
onHelperSpawn()
let stdout = ''
let stderr = ''
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComputerUsePermissionStatusResult } from '../../shared/computer-use-permissions-types'
import type * as PermissionsModule from './macos-computer-use-permissions'
const mocks = vi.hoisted(() => ({
spawn: vi.fn(),
spawnSync: vi.fn(),
peek: vi.fn(),
readBundleId: vi.fn()
}))
vi.mock('node:child_process', () => ({ spawn: mocks.spawn, spawnSync: mocks.spawnSync }))
vi.mock('./macos-native-provider-paths', () => ({
resolveMacOSComputerUseAppPath: () => '/Applications/Orca Computer Use.app'
}))
vi.mock('./macos-computer-use-permission-status', () => ({
getComputerUsePermissionStatus: vi.fn(),
resolveComputerUseHelperUnavailableReason: () => null
}))
vi.mock('./macos-computer-use-permission-status-gate', () => ({
computerUsePermissionStatusGate: { peek: mocks.peek, read: mocks.peek, invalidate: () => {} }
}))
vi.mock('../macos-tcc-reset', () => ({
readMacosBundleId: mocks.readBundleId,
resetMacosTccPermission: async () => ({ ok: true })
}))
const denied: ComputerUsePermissionStatusResult = {
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
helperUnavailableReason: null,
permissions: [
{ id: 'accessibility', status: 'not-granted' },
{ id: 'screenshots', status: 'not-granted' }
]
}
function setupLaunches(): unknown[] {
return mocks.spawn.mock.calls
.filter(([program]) => program === '/usr/bin/open')
.map(([, args]) => (Array.isArray(args) ? args.slice(3) : []))
}
function deferredStatus(): {
promise: Promise<ComputerUsePermissionStatusResult>
resolve: () => void
} {
let resolve = (): void => {}
const promise = new Promise<ComputerUsePermissionStatusResult>((settle) => {
resolve = () => settle(denied)
})
return { promise, resolve }
}
// Fresh modules per test: the shutdown flag and setup window are process-wide.
async function loadModules(): Promise<{
permissions: typeof PermissionsModule
markHostShuttingDown: () => void
}> {
const permissions = await import('./macos-computer-use-permissions')
const { markHostShuttingDown } = await import('../host-shutdown-state')
return { permissions, markHostShuttingDown }
}
describe('computer-use setup cancellation', () => {
const originalPlatform = process.platform
beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
mocks.spawn.mockReset().mockReturnValue({ unref: () => {} })
mocks.spawnSync.mockReset()
mocks.peek.mockReset().mockResolvedValue(denied)
mocks.readBundleId.mockReset().mockResolvedValue(null)
Object.defineProperty(process, 'platform', { value: 'darwin' })
})
afterEach(() => {
vi.useRealTimers()
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
it('never spawns a queued helper once the host is shutting down', async () => {
const { permissions, markHostShuttingDown } = await loadModules()
await permissions.openComputerUsePermissions('accessibility')
await permissions.openComputerUsePermissions('screenshots')
markHostShuttingDown()
await vi.advanceTimersByTimeAsync(5_000)
expect(setupLaunches()).toEqual([['--permission', 'accessibility']])
})
it('cancels an Open whose status resolves during shutdown, as an error', async () => {
const status = deferredStatus()
mocks.peek.mockReturnValue(status.promise)
const { permissions, markHostShuttingDown } = await loadModules()
const open = permissions.openComputerUsePermissions('accessibility')
markHostShuttingDown()
status.resolve()
await expect(open).rejects.toThrow('cancelled')
expect(setupLaunches()).toEqual([])
})
it('cancels an Open that predates a reset, and the next Open still launches', async () => {
const status = deferredStatus()
mocks.peek.mockReturnValueOnce(status.promise)
const { permissions } = await loadModules()
const stale = permissions.openComputerUsePermissions('accessibility')
await permissions.resetComputerUsePermissions()
const fresh = permissions.openComputerUsePermissions('accessibility')
status.resolve()
await expect(stale).rejects.toThrow('cancelled')
await expect(fresh).resolves.toMatchObject({ launchedHelper: true, launchQueued: false })
expect(setupLaunches()).toEqual([['--permission', 'accessibility']])
})
it('lets the newest request win when an older one resumes after it launched', async () => {
const status = deferredStatus()
mocks.peek.mockReturnValueOnce(status.promise).mockReturnValueOnce(status.promise)
const { permissions } = await loadModules()
// A1 and B wait on a slow probe; A2 joins an answer that is already in.
const a1 = permissions.openComputerUsePermissions('accessibility')
const b = permissions.openComputerUsePermissions('screenshots')
await permissions.openComputerUsePermissions('accessibility')
status.resolve()
await Promise.all([a1, b])
await vi.advanceTimersByTimeAsync(10_000)
expect(setupLaunches()).toEqual([['--permission', 'accessibility']])
})
it('cancels an untargeted Open during shutdown even when everything is granted', async () => {
mocks.peek.mockResolvedValue({
...denied,
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'granted' }
]
})
const { permissions, markHostShuttingDown } = await loadModules()
markHostShuttingDown()
await expect(permissions.openComputerUsePermissions()).rejects.toThrow('cancelled')
})
it('cancels a queued launch before reset waits on the bundle id', async () => {
const { permissions } = await loadModules()
await permissions.openComputerUsePermissions('accessibility')
await permissions.openComputerUsePermissions('screenshots')
let finishBundleRead = (): void => {}
mocks.readBundleId.mockReturnValue(
new Promise<null>((resolve) => {
finishBundleRead = () => resolve(null)
})
)
const reset = permissions.resetComputerUsePermissions()
await vi.advanceTimersByTimeAsync(6_000)
finishBundleRead()
await reset
expect(setupLaunches()).toEqual([['--permission', 'accessibility']])
})
})
@@ -7,6 +7,12 @@ import {
openComputerUsePermissions,
resetComputerUsePermissions
} from './macos-computer-use-permissions'
import { getComputerUsePermissionStatus } from './macos-computer-use-permission-status'
import type * as permissionStatusGateModule from './macos-computer-use-permission-status-gate'
import {
createPermissionStatusGate,
type PermissionStatusGate
} from './macos-computer-use-permission-status-gate'
const resolveHelperAppPathMock = vi.hoisted(() => vi.fn())
const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn())
@@ -15,6 +21,18 @@ const helperAppPath = '/Applications/Orca Computer Use.app'
const helperInfoPlistPath = join(helperAppPath, 'Contents', 'Info.plist')
const plistBuddyStdout = vi.hoisted(() => ({ value: 'com.example.orca.computer-use\n' }))
let monotonicOffset = 0
const gate = vi.hoisted((): { current: PermissionStatusGate | null } => ({ current: null }))
// A fresh gate per test so one test's floor or cached grant never leaks into the next.
vi.mock('./macos-computer-use-permission-status-gate', async (importOriginal) => ({
...(await importOriginal<typeof permissionStatusGateModule>()),
computerUsePermissionStatusGate: {
read: () => gate.current!.read(),
peek: () => gate.current!.peek(),
invalidate: () => gate.current!.invalidate()
}
}))
vi.mock('child_process', () => ({
execFileSync: vi.fn(),
@@ -60,6 +78,10 @@ describe('openComputerUsePermissions', () => {
vi.mocked(stat).mockResolvedValue({} as Awaited<ReturnType<typeof stat>>)
mockPermissionStatus('{"accessibility":"granted","screenshots":"granted"}')
setPlatform('darwin')
// Fake timers restart the monotonic clock; step past any earlier test's setup window.
vi.useFakeTimers()
vi.advanceTimersByTime((monotonicOffset += 600_000))
gate.current = createPermissionStatusGate(getComputerUsePermissionStatus)
})
afterEach(() => {
@@ -99,6 +121,7 @@ describe('openComputerUsePermissions', () => {
permissionId: undefined,
openedSettings: false,
launchedHelper: true,
launchQueued: false,
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'not-granted' }
@@ -132,6 +155,7 @@ describe('openComputerUsePermissions', () => {
permissionId: 'accessibility',
openedSettings: true,
launchedHelper: true,
launchQueued: false,
permissions: [
{ id: 'accessibility', status: 'not-granted' },
{ id: 'screenshots', status: 'not-granted' }
@@ -155,6 +179,7 @@ describe('openComputerUsePermissions', () => {
permissionId: 'accessibility',
openedSettings: true,
launchedHelper: true,
launchQueued: false,
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'not-granted' }
@@ -205,9 +230,9 @@ describe('openComputerUsePermissions', () => {
it('resets stale macOS TCC grants for the helper bundle id', async () => {
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
vi.mocked(readFile)
.mockResolvedValueOnce('{"accessibility":"granted","screenshots":"granted"}')
.mockResolvedValueOnce('{"accessibility":"not-granted","screenshots":"not-granted"}')
vi.mocked(readFile).mockResolvedValueOnce(
'{"accessibility":"not-granted","screenshots":"not-granted"}'
)
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType<typeof spawnSync>)
await expect(resetComputerUsePermissions()).resolves.toEqual({
@@ -245,6 +270,189 @@ describe('openComputerUsePermissions', () => {
})
})
describe('setup helper launch window', () => {
const originalPlatform = process.platform
beforeEach(() => {
vi.mocked(spawn)
.mockReset()
.mockImplementation(fakeSpawnDispatch(() => ({})))
vi.mocked(spawnSync).mockClear()
vi.mocked(mkdtemp).mockResolvedValue(permissionStatusTempDir)
vi.mocked(stat).mockResolvedValue({} as Awaited<ReturnType<typeof stat>>)
resolveHelperAppPathMock.mockReturnValue(helperAppPath)
resolveHelperExecutablePathMock.mockReturnValue(
`${helperAppPath}/Contents/MacOS/orca-computer-use-macos`
)
mockPermissionStatus('{"accessibility":"not-granted","screenshots":"not-granted"}')
setPlatform('darwin')
vi.useFakeTimers()
vi.advanceTimersByTime((monotonicOffset += 600_000))
gate.current = createPermissionStatusGate(getComputerUsePermissionStatus)
})
afterEach(async () => {
// Drain any queued launch so it cannot carry into the next test.
await vi.advanceTimersByTimeAsync(5_000)
vi.useRealTimers()
setPlatform(originalPlatform)
})
const setupLaunches = (): string[][] =>
vi
.mocked(spawn)
.mock.calls.filter(
([program, args]) =>
program === '/usr/bin/open' &&
Array.isArray(args) &&
!args.includes('--permission-status-file')
)
.map(([, args]) => (Array.isArray(args) ? args.slice(3) : []))
const setupKills = (): number =>
vi.mocked(spawnSync).mock.calls.filter(([program]) => program === '/usr/bin/pkill').length
it('launches once per window across permission keys and queues the latest request', async () => {
const results = await Promise.all([
openComputerUsePermissions('accessibility'),
openComputerUsePermissions('screenshots'),
openComputerUsePermissions()
])
expect(setupLaunches()).toEqual([['--permission', 'accessibility']])
expect(results.map((result) => result.launchQueued)).toEqual([false, true, true])
expect(results[1]).toMatchObject({ openedSettings: false, launchedHelper: true })
await vi.advanceTimersByTimeAsync(4_999)
expect(setupLaunches()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
expect(setupLaunches()).toEqual([['--permission', 'accessibility'], ['--permissions']])
})
it('re-shows the helper on a second Open by killing and relaunching it after the window', async () => {
await openComputerUsePermissions('accessibility')
const killsAfterFirst = setupKills()
await expect(openComputerUsePermissions('accessibility')).resolves.toMatchObject({
launchQueued: true
})
await vi.advanceTimersByTimeAsync(5_000)
expect(setupLaunches()).toEqual([
['--permission', 'accessibility'],
['--permission', 'accessibility']
])
expect(setupKills()).toBe(killsAfterFirst * 2)
})
it('reuses the status it already has instead of probing again for setup', async () => {
await gate.current!.read()
await openComputerUsePermissions('accessibility')
const statusLaunches = vi
.mocked(spawn)
.mock.calls.filter(
([, args]) => Array.isArray(args) && args.includes('--permission-status-file')
)
expect(statusLaunches).toHaveLength(1)
})
it('keeps latest-wins when a later request coalesces onto an earlier one (A, B, A)', async () => {
await openComputerUsePermissions()
await Promise.all([
openComputerUsePermissions('accessibility'),
openComputerUsePermissions('screenshots'),
openComputerUsePermissions('accessibility')
])
await vi.advanceTimersByTimeAsync(5_000)
expect(setupLaunches()).toEqual([['--permissions'], ['--permission', 'accessibility']])
})
it('lets an A that arrives after A launches queue and win over B', async () => {
await openComputerUsePermissions()
await vi.advanceTimersByTimeAsync(5_000)
const first = openComputerUsePermissions('accessibility')
const second = openComputerUsePermissions('screenshots')
while (setupLaunches().length < 2) {
await Promise.resolve()
}
const third = openComputerUsePermissions('accessibility')
await Promise.all([first, second, third])
await vi.advanceTimersByTimeAsync(5_000)
expect(setupLaunches().slice(1)).toEqual([
['--permission', 'accessibility'],
['--permission', 'accessibility']
])
})
it('spawns concurrent same-permission Opens once per window', async () => {
await Promise.all(Array.from({ length: 5 }, () => openComputerUsePermissions('accessibility')))
await vi.advanceTimersByTimeAsync(10_000)
expect(setupLaunches()).toEqual([['--permission', 'accessibility']])
})
it('never fires a queued setup spawn early when the timer delay is fractional', async () => {
const spawnedAt: number[] = []
vi.mocked(spawn).mockImplementation((program, args) => {
if (program === '/usr/bin/open') {
spawnedAt.push(performance.now())
}
return fakeSpawnDispatch(() => ({}))(program, Array.isArray(args) ? args : [])
})
await openComputerUsePermissions('accessibility')
// Timers truncate fractional delays, so this one would fire half a millisecond early.
vi.advanceTimersByTime(0.5)
await openComputerUsePermissions('screenshots')
await vi.advanceTimersByTimeAsync(6_000)
const setupSpawns = spawnedAt.slice(-2)
expect(setupSpawns[1]! - setupSpawns[0]!).toBeGreaterThanOrEqual(5_000)
})
it('spaces real setup spawns 5s apart even when pkill is slow', async () => {
const spawnedAt: number[] = []
let pkillDelayMs = 100
const pkillResult: ReturnType<typeof spawnSync> = {
pid: 0,
output: [],
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
status: 0,
signal: null
}
vi.mocked(spawnSync).mockImplementation(() => {
vi.advanceTimersByTime(pkillDelayMs)
return pkillResult
})
vi.mocked(spawn).mockImplementation((program, args) => {
if (program === '/usr/bin/open') {
spawnedAt.push(performance.now())
}
return fakeSpawnDispatch(() => ({}))(program, Array.isArray(args) ? args : [])
})
await openComputerUsePermissions('accessibility')
pkillDelayMs = 0
await openComputerUsePermissions('screenshots')
await vi.advanceTimersByTimeAsync(6_000)
const setupSpawns = spawnedAt.slice(-2)
expect(setupSpawns).toHaveLength(2)
expect(setupSpawns[1]! - setupSpawns[0]!).toBeGreaterThanOrEqual(5_000)
})
it('never serves a seeded grant after a partial reset failure', async () => {
mockPermissionStatus('{"accessibility":"granted","screenshots":"granted"}')
await gate.current!.read()
vi.mocked(spawn).mockImplementation(
fakeSpawnDispatch((program, args) =>
program === '/usr/bin/tccutil' && args[1] === 'ScreenCapture' ? { code: 1 } : {}
)
)
await expect(resetComputerUsePermissions()).rejects.toThrow('Could not reset ScreenCapture')
mockPermissionStatus('{"accessibility":"not-granted","screenshots":"granted"}')
const afterReset = gate.current!.read()
await vi.advanceTimersByTimeAsync(5_000)
await expect(afterReset).resolves.toMatchObject({
permissions: expect.arrayContaining([{ id: 'accessibility', status: 'not-granted' }])
})
})
})
function mockPermissionStatus(json: string): void {
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType<typeof spawnSync>)
vi.mocked(readFile).mockResolvedValue(json)
@@ -1,8 +1,10 @@
import { spawn, spawnSync } from 'node:child_process'
import { RuntimeClientError } from './runtime-client-error'
import { isHostShuttingDown } from '../host-shutdown-state'
import { readMacosBundleId, resetMacosTccPermission } from '../macos-tcc-reset'
import { resolveMacOSComputerUseAppPath } from './macos-native-provider-paths'
import { getComputerUsePermissionStatus } from './macos-computer-use-permission-status'
import { resolveComputerUseHelperUnavailableReason } from './macos-computer-use-permission-status'
import { computerUsePermissionStatusGate } from './macos-computer-use-permission-status-gate'
import type {
ComputerUsePermissionId,
ComputerUsePermissionResetResult,
@@ -14,14 +16,115 @@ const DEFAULT_COMPUTER_USE_BUNDLE_ID = 'com.stablyai.orca.computer-use'
export { getComputerUsePermissionStatus } from './macos-computer-use-permission-status'
// One setup-helper spawn per 5s across every permission, on the monotonic clock; claimed synchronously.
const SETUP_HELPER_LAUNCH_WINDOW_MS = 5_000
// Requests are numbered on arrival; a queued request loses to any later one (latest wins).
type SetupLaunchRequest = { seq: number; helperAppPath: string; helperArgs: string[] }
let setupRequestSeq = 0
let lastLaunch: { seq: number; coversSeq: number; argsKey: string; at: number } | null = null
let queuedSetupLaunch: SetupLaunchRequest | null = null
let queuedSetupTimer: ReturnType<typeof setTimeout> | null = null
// Bumped by reset so Opens still waiting on status never launch afterwards.
let setupGeneration = 0
// Opens that arrive mid-reset wait for it, so reset's helper cleanup can't kill what they open.
let resetInProgress: Promise<unknown> | null = null
export function openComputerUsePermissions(
permissionId?: ComputerUsePermissionId
): Promise<ComputerUsePermissionSetupResult> {
return openComputerUsePermissionsAsync(permissionId)
return openComputerUsePermissionsAsync(permissionId, ++setupRequestSeq, setupGeneration)
}
function cancelPendingSetupLaunches(): void {
setupGeneration += 1
if (queuedSetupTimer) {
clearTimeout(queuedSetupTimer)
queuedSetupTimer = null
}
queuedSetupLaunch = null
}
const setupWindowOpensAt = (): number =>
(lastLaunch?.at ?? Number.NEGATIVE_INFINITY) + SETUP_HELPER_LAUNCH_WINDOW_MS
// Launches now, or queues for when the window reopens; a later request supersedes a queued one.
function requestSetupHelperLaunch(
request: SetupLaunchRequest
): 'launched' | 'queued' | 'superseded' {
// A newer request already spawned; showing this older one now would replace it.
if (lastLaunch && request.seq <= lastLaunch.seq) {
return 'superseded'
}
if (performance.now() >= setupWindowOpensAt() && !queuedSetupLaunch) {
launchSetupHelper(request)
return 'launched'
}
if (!queuedSetupLaunch || request.seq > queuedSetupLaunch.seq) {
queuedSetupLaunch = request
}
armQueuedSetupTimer()
return 'queued'
}
function armQueuedSetupTimer(): void {
if (queuedSetupTimer) {
return
}
queuedSetupTimer = setTimeout(
() => {
queuedSetupTimer = null
// Timers can fire a fraction early; never spawn inside the window.
if (performance.now() < setupWindowOpensAt()) {
armQueuedSetupTimer()
return
}
const queued = queuedSetupLaunch
queuedSetupLaunch = null
// Same-args requests that arrived before the last spawn were already shown by it.
const covered =
queued &&
lastLaunch &&
(queued.seq <= lastLaunch.seq ||
(lastLaunch.argsKey === queued.helperArgs.join(' ') &&
queued.seq <= lastLaunch.coversSeq))
if (queued && !covered) {
try {
launchSetupHelper(queued)
} catch (error) {
console.error('[computer-use] queued setup helper launch failed:', error)
}
}
},
Math.max(0, Math.ceil(setupWindowOpensAt() - performance.now()))
)
queuedSetupTimer.unref?.()
}
function launchSetupHelper(request: SetupLaunchRequest): void {
if (isHostShuttingDown()) {
return
}
// Kill and relaunch so a repeated click always brings the helper back to the front.
closeExistingPermissionHelpers()
const helper = spawn(
'/usr/bin/open',
['-n', request.helperAppPath, '--args', ...request.helperArgs],
{ detached: true, stdio: 'ignore' }
)
// Measured after the spawn so a slow pkill can't pull the next spawn inside the window.
lastLaunch = {
seq: request.seq,
coversSeq: setupRequestSeq,
argsKey: request.helperArgs.join(' '),
at: performance.now()
}
helper.unref()
}
async function openComputerUsePermissionsAsync(
permissionId?: ComputerUsePermissionId
permissionId: ComputerUsePermissionId | undefined,
seq: number,
generation: number
): Promise<ComputerUsePermissionSetupResult> {
if (process.platform !== 'darwin') {
return {
@@ -42,11 +145,22 @@ async function openComputerUsePermissionsAsync(
if (!helperAppPath) {
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
const status = await getComputerUsePermissionStatus()
// Wait out a reset before reading status, so this Open never sees pre-reset grants.
while (resetInProgress) {
await resetInProgress.catch(() => {})
}
const status = await computerUsePermissionStatusGate.peek()
if (status.helperUnavailableReason) {
throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason)
}
const nextStep = nextPermissionStep(status.permissions)
if (generation !== setupGeneration || isHostShuttingDown()) {
// An error rather than launchedHelper: false, which clients render as "setup complete".
throw new RuntimeClientError(
'accessibility_error',
'Computer Use setup was cancelled because permissions were reset or Orca is quitting'
)
}
if (!permissionId && !nextStep) {
return {
@@ -59,31 +173,35 @@ async function openComputerUsePermissionsAsync(
nextStep
}
}
closeExistingPermissionHelpers()
const helperArgs = permissionId ? ['--permission', permissionId] : ['--permissions']
const helper = spawn('/usr/bin/open', ['-n', helperAppPath, '--args', ...helperArgs], {
detached: true,
stdio: 'ignore'
})
helper.unref()
const launch = requestSetupHelperLaunch({ seq, helperAppPath, helperArgs })
return {
platform: process.platform,
helperAppPath,
permissionId,
openedSettings: permissionId !== undefined,
openedSettings: launch === 'launched' && permissionId !== undefined,
// Also true when superseded: a newer request's helper is already showing.
launchedHelper: true,
launchQueued: launch === 'queued',
permissions: status.permissions,
nextStep
}
}
export function resetComputerUsePermissions(): Promise<ComputerUsePermissionResetResult> {
return resetComputerUsePermissionsAsync()
const reset = resetComputerUsePermissionsAsync().finally(() => {
if (resetInProgress === reset) {
resetInProgress = null
}
})
resetInProgress = reset
return reset
}
async function resetComputerUsePermissionsAsync(): Promise<ComputerUsePermissionResetResult> {
// Before any await, so a queued or preflighting Open can't slip a helper in during the reset.
cancelPendingSetupLaunches()
if (process.platform !== 'darwin') {
return {
platform: process.platform,
@@ -102,18 +220,23 @@ async function resetComputerUsePermissionsAsync(): Promise<ComputerUsePermission
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
const status = await getComputerUsePermissionStatus()
if (status.helperUnavailableReason) {
throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason)
const unavailableReason = resolveComputerUseHelperUnavailableReason()
if (unavailableReason) {
throw new RuntimeClientError('accessibility_error', unavailableReason)
}
const bundleId = (await readMacosBundleId(helperAppPath)) ?? DEFAULT_COMPUTER_USE_BUNDLE_ID
closeExistingPermissionHelpers()
await resetTccPermission('Accessibility', bundleId)
await resetTccPermission('ScreenCapture', bundleId)
try {
await resetTccPermission('Accessibility', bundleId)
await resetTccPermission('ScreenCapture', bundleId)
} finally {
// Even a partial reset may have revoked grants; never serve the pre-reset snapshot again.
computerUsePermissionStatusGate.invalidate()
}
return {
...(await getComputerUsePermissionStatus()),
...(await computerUsePermissionStatusGate.read()),
bundleId
}
}
-5
View File
@@ -14,7 +14,6 @@ import {
import { isComputerSidecarDiagnostic, logComputerDiagnostic } from './computer-sidecar-diagnostics'
import { validateComputerSidecarPasteText } from './computer-sidecar-paste-validation'
import { RuntimeClientError } from './runtime-client-error'
import { computerUsePermissionStatusGate } from './macos-computer-use-permission-status-gate'
import { getForkedChildEntryPath } from '../forked-child-entry-path'
// Bundled beside orcad.js on servers and under out/main on desktop.
@@ -261,10 +260,6 @@ class ComputerSidecarProcess {
pending.resolve(message.result)
return
}
if (message.error.code === 'permission_denied' && process.platform === 'darwin') {
// The provider's own TCC check outranks any cached status.
computerUsePermissionStatusGate.invalidate()
}
pending.reject(new RuntimeClientError(message.error.code, message.error.message))
}
+10
View File
@@ -0,0 +1,10 @@
// Set synchronously once the host commits to shutting down (Electron will-quit, orcad shutdown admission).
let hostShuttingDown = false
export function markHostShuttingDown(): void {
hostShuttingDown = true
}
export function isHostShuttingDown(): boolean {
return hostShuttingDown
}
@@ -1,69 +0,0 @@
import type { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
type HoistedMocks = {
app: EventEmitter | null
handlers: Map<string, (...args: unknown[]) => unknown>
focusedWindow: { current: object | null }
probe: ReturnType<typeof vi.fn>
}
const mocks = vi.hoisted((): HoistedMocks => ({
app: null,
handlers: new Map(),
focusedWindow: { current: null },
probe: vi.fn()
}))
vi.mock('electron', async () => {
const { EventEmitter: Emitter } = await import('node:events')
mocks.app = new Emitter()
return {
app: mocks.app,
BrowserWindow: { getFocusedWindow: () => mocks.focusedWindow.current },
ipcMain: {
handle: (name: string, handler: (...args: unknown[]) => unknown) =>
mocks.handlers.set(name, handler)
}
}
})
vi.mock('../computer/macos-computer-use-permissions', () => ({
getComputerUsePermissionStatus: mocks.probe
}))
describe('registerComputerUsePermissionHandlers', () => {
const originalPlatform = process.platform
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
it('re-probes passive readers only after the app itself returns', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
mocks.probe.mockResolvedValue({
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
helperUnavailableReason: null,
permissions: [
{ id: 'accessibility', status: 'not-granted' },
{ id: 'screenshots', status: 'granted' }
]
})
const { registerComputerUsePermissionHandlers } = await import('./computer-use-permissions')
registerComputerUsePermissionHandlers()
const app = mocks.app!
const readPassively = (): unknown =>
mocks.handlers.get('computerUsePermissions:getStatus')!({}, { background: true })
mocks.focusedWindow.current = {}
await readPassively()
await readPassively()
expect(mocks.probe).toHaveBeenCalledTimes(1)
app.emit('did-resign-active')
app.emit('did-become-active')
await readPassively()
expect(mocks.probe).toHaveBeenCalledTimes(2)
})
})
+4 -23
View File
@@ -1,24 +1,13 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { ipcMain } from 'electron'
import type {
ComputerUsePermissionId,
ComputerUsePermissionResetResult,
ComputerUsePermissionSetupResult,
ComputerUsePermissionStatusResult
} from '../../shared/computer-use-permissions-types'
import {
bindPermissionStatusGateToAppActivity,
computerUsePermissionStatusGate,
type PermissionStatusReadOptions
} from '../computer/macos-computer-use-permission-status-gate'
import { computerUsePermissionStatusGate } from '../computer/macos-computer-use-permission-status-gate'
export function registerComputerUsePermissionHandlers(): void {
if (process.platform === 'darwin') {
bindPermissionStatusGateToAppActivity(computerUsePermissionStatusGate, {
onDeparture: (listener) => app.on('did-resign-active', listener),
onReturn: (listener) => app.on('did-become-active', listener),
isAppActive: () => BrowserWindow.getFocusedWindow() !== null
})
}
ipcMain.handle(
'computerUsePermissions:openSetup',
async (
@@ -27,27 +16,19 @@ export function registerComputerUsePermissionHandlers(): void {
): Promise<ComputerUsePermissionSetupResult> => {
const { openComputerUsePermissions } =
await import('../computer/macos-computer-use-permissions')
computerUsePermissionStatusGate.invalidate()
return openComputerUsePermissions(args?.id)
}
)
ipcMain.handle(
'computerUsePermissions:getStatus',
(_event, args?: PermissionStatusReadOptions): Promise<ComputerUsePermissionStatusResult> =>
computerUsePermissionStatusGate.read({ background: args?.background === true })
(): Promise<ComputerUsePermissionStatusResult> => computerUsePermissionStatusGate.read()
)
ipcMain.handle(
'computerUsePermissions:reset',
async (): Promise<ComputerUsePermissionResetResult> => {
const { resetComputerUsePermissions } =
await import('../computer/macos-computer-use-permissions')
computerUsePermissionStatusGate.invalidate()
try {
return await resetComputerUsePermissions()
} finally {
// Reads during the reset may have cached the pre-reset grants.
computerUsePermissionStatusGate.invalidate()
}
return resetComputerUsePermissions()
}
)
}
+4
View File
@@ -1,4 +1,5 @@
import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
import { markHostShuttingDown } from '../host-shutdown-state'
import { setDesktopBrowserBridgeHostEnabled } from '../runtime/desktop-browser-bridge/desktop-browser-bridge-registry'
import { ORCAD_MANAGED_ACTIVATION_ROOT_ENV } from '../../shared/orcad-managed-launch-env'
import { startOrcadBrowserProvider } from './orcad-browser-startup'
@@ -59,6 +60,7 @@ export function installOrcadShutdownSignals(
return false
}
stopping = true
markHostShuttingDown()
holdOrcadTerminalAdmissionUntilExit()
exitCode = stoppedExitCode
retractClean = onFailed
@@ -105,6 +107,8 @@ export async function startOrcadWithLifecycle<T extends object>(
const cleanup = createIdempotentOrcadCleanup(async () => {
// Stamp quit before awaiting teardown so dying chat agents read as restart rather than crash.
recordAgentSessionRuntimeEnd('quit')
// Startup failures reach cleanup without shutdown admission while RPC may already be serving.
markHostShuttingDown()
let runtimeCleanupSucceeded = false
try {
await runtime.stop()
@@ -89,7 +89,7 @@ describe('computer RPC methods', () => {
computerMocks.getComputerUsePermissionStatus.mockReturnValue(result)
await expect(call('computer.permissionsStatus', {})).resolves.toBe(result)
expect(computerMocks.getComputerUsePermissionStatus).toHaveBeenCalledWith()
expect(computerMocks.getComputerUsePermissionStatus).toHaveBeenCalledWith(expect.any(Function))
})
it('lists windows through the sidecar', async () => {
-3
View File
@@ -55,9 +55,6 @@ export const COMPUTER_METHODS = [
handler: async (params) => {
const { openComputerUsePermissions } =
await import('../../../computer/macos-computer-use-permissions')
const { computerUsePermissionStatusGate } =
await import('../../../computer/macos-computer-use-permission-status-gate')
computerUsePermissionStatusGate.invalidate()
return openComputerUsePermissions(params.id)
}
}),
+2
View File
@@ -1,4 +1,5 @@
import { killWslChannelsInCloseGrace } from '../wsl/wsl-command-channel'
import { markHostShuttingDown } from '../host-shutdown-state'
import { app, type Event } from 'electron'
import { closeAllWatchers } from '../ipc/filesystem/watchers/filesystem-watcher-shutdown'
import { disposeWorktreeBaseDirectoryWatchers } from '../ipc/worktree-base-directory-watcher'
@@ -111,6 +112,7 @@ function installWillQuitHandler(): void {
// Include pane grids still inside their write burst in final save.
const paneGridFlush = state.runtime?.flushPaneGrids() ?? null
// A renderer can veto before-quit; push must survive until quit is committed.
markHostShuttingDown()
state.desktopPushService?.stop()
state.unsubscribeSystemResumeBroadcast?.()
state.unsubscribeSystemResumeBroadcast = null
@@ -2,8 +2,7 @@ import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const computerUsePermissionsApi = {
getStatus: (args?: { background?: boolean }) =>
ipcRenderer.invoke('computerUsePermissions:getStatus', args),
getStatus: () => ipcRenderer.invoke('computerUsePermissions:getStatus'),
openSetup: (args?: { id?: string }) =>
ipcRenderer.invoke('computerUsePermissions:openSetup', args),
reset: () => ipcRenderer.invoke('computerUsePermissions:reset')
+1 -2
View File
@@ -61,8 +61,7 @@ export type DeveloperPermissionsApi = {
}
export type ComputerUsePermissionsApi = {
// Passive badges pass background to accept a backed-off denial instead of relaunching the helper.
getStatus: (args?: { background?: boolean }) => Promise<ComputerUsePermissionStatusResult>
getStatus: () => Promise<ComputerUsePermissionStatusResult>
openSetup: (args?: { id?: ComputerUsePermissionId }) => Promise<ComputerUsePermissionSetupResult>
reset: () => Promise<ComputerUsePermissionResetResult>
}
@@ -275,7 +275,7 @@ function useComputerUsePermissionStatus(enabled: boolean): {
const refresh = (): void => {
setStatus((current) => ({ ...current, checking: true }))
window.api.computerUsePermissions
.getStatus({ background: true })
.getStatus()
.then((next) => {
if (stale) {
return
@@ -197,7 +197,14 @@ export function ComputerUsePane(): React.JSX.Element {
if (!mountedRef.current) {
return
}
if (result.launchedHelper) {
if (result.launchQueued) {
toast.message(
translate(
'auto.components.settings.ComputerUsePane.794807aa21',
'Queued macOS Privacy & Security; a newer request or a reset may replace it'
)
)
} else if (result.launchedHelper) {
toast.message(
translate(
'auto.components.settings.ComputerUsePane.697005758f',
@@ -191,9 +191,7 @@ export function useSetupGuideProgress(
}, [orderedGitRepos, settings, setupScriptProbeSignature, shouldRefreshCoreState])
const readComputerUsePermissions = useCallback(async (isStale: () => boolean): Promise<void> => {
const status = await window.api.computerUsePermissions
.getStatus({ background: true })
.catch(() => null)
const status = await window.api.computerUsePermissions.getStatus().catch(() => null)
if (isStale()) {
return
}
+1
View File
@@ -7853,6 +7853,7 @@
"7801ac08ec": "Computer Use permissions are only required on macOS",
"740766c291": "Computer Use setup is already complete",
"697005758f": "Opened macOS Privacy & Security",
"794807aa21": "Queued macOS Privacy & Security; a newer request or a reset may replace it",
"2168fa5ab0": "Could not load Computer Use permissions",
"0c9a33f468": "Capture app windows so agents can inspect visual state.",
"07bbe4c4cb": "Screenshots",
@@ -114,7 +114,6 @@ export function createComputerUsePermissionsApi(): NonNullable<
Partial<PreloadApi>['computerUsePermissions']
> {
return {
// Browser focus can't activate the host app, so remote reads never opt into the denied backoff.
getStatus: () =>
callRuntimeResult<ComputerUsePermissionStatusResult>(
'computer.permissionsStatus',
@@ -126,13 +125,19 @@ export function createComputerUsePermissionsApi(): NonNullable<
'computer.permissions',
args ?? {},
15_000
).catch(() => ({
platform: getBrowserPlatform(),
helperAppPath: null,
openedSettings: false,
launchedHelper: false,
nextStep: 'Computer-use permissions are managed on the Orca server.'
})),
).catch((error: unknown) => {
// Only a host without this method falls back; real failures (e.g. a cancelled Open) must surface.
if (!(error instanceof Error && 'code' in error && error.code === 'method_not_found')) {
throw error
}
return {
platform: getBrowserPlatform(),
helperAppPath: null,
openedSettings: false,
launchedHelper: false,
nextStep: 'Computer-use permissions are managed on the Orca server.'
}
}),
reset: () =>
Promise.resolve({
platform: getBrowserPlatform(),
@@ -718,10 +718,7 @@ describe('web UI preload API', () => {
skills: [{ name: 'computer-use', installed: true }],
scannedAt: 123
})
// Passive local callers pass background; the browser can't activate the host, so it must not reach RPC.
const permissionsStatus = await globals.window.api.computerUsePermissions.getStatus({
background: true
})
const permissionsStatus = await globals.window.api.computerUsePermissions.getStatus()
expect(permissionsStatus.helperUnavailableReason).toBeNull()
expect(permissionsStatus.permissions).toContainEqual({ id: 'accessibility', status: 'granted' })
await expect(
@@ -831,4 +828,38 @@ describe('web UI preload API', () => {
'runtime disconnected'
)
})
it('surfaces a cancelled paired web setup instead of reporting it as complete', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
if (method === 'computer.permissions') {
return Promise.resolve({
id: method,
ok: false,
error: { code: 'accessibility_error', message: 'Computer Use setup was cancelled' },
_meta: { runtimeId: 'runtime-1' }
})
}
return Promise.resolve({
id: method,
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(
globals.window.api.computerUsePermissions.openSetup({ id: 'accessibility' })
).rejects.toThrow('Computer Use setup was cancelled')
})
})
@@ -19,7 +19,10 @@ export type ComputerUsePermissionSetupResult = {
helperAppPath: string | null
permissionId?: ComputerUsePermissionId
openedSettings: boolean
// True when a helper launched, is showing for a newer request, or is queued; older clients only read this.
launchedHelper: boolean
// Queued for when the 5s setup window reopens; a newer request or a reset may replace it.
launchQueued?: boolean
permissions?: ComputerUsePermissionState[]
nextStep?: string | null
}