fix(computer-use): stop relaunching the macOS permission helper on every status read (#34390) (#36028)

* fix(computer-use): stop relaunching the macOS permission helper while Accessibility is denied (#34390)

Every computerUsePermissions:getStatus / computer.permissionsStatus call launched a
fresh helper via open -n that polls TCC for up to ~3.5s. Three always-mounted
setup-guide hooks re-read on every window focus, so a denied Accessibility state
produced batches of helper launches and continuous TCCAccessRequest traffic.

Gate status reads in main: coalesce concurrent reads, reuse granted results for 2s,
and back off denied/failed results 10s doubling to a 120s cap. Window focus lowers
the backoff to a 10s floor; openSetup/reset invalidate it.

* fix(computer-use): never serve stale macOS permission status after the user acts (#34390)

- Re-probe once per real return to Orca (blur then focus) instead of a 10s floor,
  so grants and revocations made in System Settings show without another focus.
- computer.permissionsStatus accepts an optional fresh flag (web clients send it)
  that bypasses the denied backoff; older hosts strip it, older clients omit it.
- A provider permission_denied reply invalidates the cached status.
- Invalidate again after a TCC reset; stale in-flight probes get one follow-up
  probe instead of an overlapping helper.
- Failed checks are shared for 2s and retried, not backed off like a denial.

* fix(computer-use): gate macOS permission re-probes on app-level returns only (#34390)

- Track did-resign-active/did-become-active instead of BrowserWindow blur/focus,
  so moving between Orca's own windows never re-launches the helper.
- If the renderer's focus read reaches main before did-become-active, treat it as
  the return when a window is focused, so the first read after return is fresh.
- Passive badges (setup-guide progress, feature-wall status) opt into the denied
  backoff with background: true; omitted flags (older clients, active setup
  screens) keep near-live reads with 2s reuse. Replaces the unreleased fresh flag.
- Obsolete probes no longer record outcomes or advance the denied backoff.

* test(computer-use): drop casts from permission IPC wiring test

* fix(computer-use): keep remote permission reads near-live and count startup as away (#34390)

- Web clients never forward background: browser focus can't activate the host,
  so remote setup surfaces get 2s reuse instead of a stale denial.
- Seed the departed state when binding app activity, so a grant made during a
  slow startup is picked up on the first return.
- Drop the mock-only sidecar invalidation test; keep gate constants private.

* fix(computer-use): never serve a cached denial to foreground permission reads (#34390)

- Active screens, web clients and older clients only reuse a denial by joining
  an in-flight probe; granted and error results keep the 2s reuse.
- Revert the unreleased RPC background option so remote reads stay foreground.
- Drop the IPC test half that exercised events production no longer listens to.
This commit is contained in:
Neil
2026-10-29 10:55:44 -07:00
committed by GitHub
parent 80a2041142
commit 53e80ef9d4
12 changed files with 471 additions and 15 deletions
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest'
import type {
ComputerUsePermissionStatus,
ComputerUsePermissionStatusResult
} from '../../shared/computer-use-permissions-types'
import {
bindPermissionStatusGateToAppActivity,
createPermissionStatusProbeGate
} from './macos-computer-use-permission-status-gate'
function statusWith(accessibility: ComputerUsePermissionStatus): ComputerUsePermissionStatusResult {
return {
platform: 'darwin',
helperAppPath: '/Applications/Orca Computer Use.app',
helperUnavailableReason: null,
permissions: [
{ id: 'accessibility', status: accessibility },
{ id: 'screenshots', status: 'granted' }
]
}
}
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 }
}
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)
})
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 })
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)
probe.mockResolvedValueOnce(denied)
gate.noteAppDeparture()
gate.noteAppReturn()
await expect(gate.read(background)).resolves.toBe(denied)
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 })
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)
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 })
const obsolete = gate.read(background)
gate.invalidate()
const current = gate.read(background)
expect(probe).toHaveBeenCalledTimes(1)
first.resolve(denied)
await expect(obsolete).resolves.toBe(denied)
await expect(current).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)
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 })
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)
expect(probe).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,186 @@
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
type ProbeOutcome =
| { ok: true; result: ComputerUsePermissionStatusResult }
| { ok: false; error: unknown }
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
invalidate: () => void
}
export type AppActivitySource = {
onDeparture: (listener: () => void) => void
onReturn: (listener: () => void) => void
isAppActive: () => boolean
}
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 noteAppReturn = (): void => {
if (departed) {
departed = false
epoch += 1
}
}
const recordOutcome = (probeEpoch: number, outcome: ProbeOutcome): void => {
if (probeEpoch !== epoch) {
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
}
),
followUp: null
}
inFlight = entry
void entry.promise
.finally(() => {
if (inFlight === entry) {
inFlight = null
}
})
.catch(() => {})
return entry.promise
}
const reuseLimit = (
entry: { outcome: ProbeOutcome; finishedAt: number },
readOptions: PermissionStatusReadOptions
): number => {
if (readOptions.background) {
return reuseUntil
}
// 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
}
const read = (
readOptions: PermissionStatusReadOptions = {}
): Promise<ComputerUsePermissionStatusResult> => {
// The renderer's focus read can reach main before did-become-active does.
if (departed && isAppActive()) {
noteAppReturn()
}
const current = inFlight
if (current) {
if (current.epoch === epoch) {
return current.promise
}
// 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
}
if (cached && cached.epoch === epoch) {
if (now() < reuseLimit(cached, readOptions)) {
const { outcome } = cached
return outcome.ok ? Promise.resolve(outcome.result) : Promise.reject(outcome.error)
}
}
return startProbe()
}
return {
read,
noteAppDeparture: () => {
departed = true
},
noteAppReturn,
setAppActiveProbe: (probe) => {
isAppActive = probe
},
invalidate: () => {
epoch += 1
deniedStreak = 0
}
}
}
// 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 {
return (
result.helperUnavailableReason === null &&
result.permissions.some((permission) => permission.status === 'not-granted')
)
}
export const computerUsePermissionStatusGate = createPermissionStatusProbeGate({
probe: async () => {
const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
return getComputerUsePermissionStatus()
}
})
+5
View File
@@ -11,6 +11,7 @@ import { normalizeComputerActionResult } from './computer-action-verification-no
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.
@@ -255,6 +256,10 @@ 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))
}
@@ -0,0 +1,69 @@
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)
})
})
+23 -7
View File
@@ -1,12 +1,24 @@
import { ipcMain } from 'electron'
import { app, BrowserWindow, 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'
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 (
@@ -15,23 +27,27 @@ 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',
async (): Promise<ComputerUsePermissionStatusResult> => {
const { getComputerUsePermissionStatus } =
await import('../computer/macos-computer-use-permissions')
return getComputerUsePermissionStatus()
}
(_event, args?: PermissionStatusReadOptions): Promise<ComputerUsePermissionStatusResult> =>
computerUsePermissionStatusGate.read({ background: args?.background === true })
)
ipcMain.handle(
'computerUsePermissions:reset',
async (): Promise<ComputerUsePermissionResetResult> => {
const { resetComputerUsePermissions } =
await import('../computer/macos-computer-use-permissions')
return resetComputerUsePermissions()
computerUsePermissionStatusGate.invalidate()
try {
return await resetComputerUsePermissions()
} finally {
// Reads during the reset may have cached the pre-reset grants.
computerUsePermissionStatusGate.invalidate()
}
}
)
}
+6 -3
View File
@@ -55,6 +55,9 @@ 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)
}
}),
@@ -63,9 +66,9 @@ export const COMPUTER_METHODS = [
permission: 'workspace',
params: ComputerPermissionsStatusParams,
handler: async () => {
const { getComputerUsePermissionStatus } =
await import('../../../computer/macos-computer-use-permissions')
return getComputerUsePermissionStatus()
const { computerUsePermissionStatusGate } =
await import('../../../computer/macos-computer-use-permission-status-gate')
return computerUsePermissionStatusGate.read()
}
}),
defineMethod({
@@ -2,7 +2,8 @@ import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const computerUsePermissionsApi = {
getStatus: () => ipcRenderer.invoke('computerUsePermissions:getStatus'),
getStatus: (args?: { background?: boolean }) =>
ipcRenderer.invoke('computerUsePermissions:getStatus', args),
openSetup: (args?: { id?: string }) =>
ipcRenderer.invoke('computerUsePermissions:openSetup', args),
reset: () => ipcRenderer.invoke('computerUsePermissions:reset')
+2 -1
View File
@@ -61,7 +61,8 @@ export type DeveloperPermissionsApi = {
}
export type ComputerUsePermissionsApi = {
getStatus: () => Promise<ComputerUsePermissionStatusResult>
// Passive badges pass background to accept a backed-off denial instead of relaunching the helper.
getStatus: (args?: { background?: boolean }) => 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()
.getStatus({ background: true })
.then((next) => {
if (stale) {
return
@@ -191,7 +191,9 @@ export function useSetupGuideProgress(
}, [orderedGitRepos, settings, setupScriptProbeSignature, shouldRefreshCoreState])
const readComputerUsePermissions = useCallback(async (isStale: () => boolean): Promise<void> => {
const status = await window.api.computerUsePermissions.getStatus().catch(() => null)
const status = await window.api.computerUsePermissions
.getStatus({ background: true })
.catch(() => null)
if (isStale()) {
return
}
@@ -114,6 +114,7 @@ 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',
@@ -718,7 +718,10 @@ describe('web UI preload API', () => {
skills: [{ name: 'computer-use', installed: true }],
scannedAt: 123
})
const permissionsStatus = await globals.window.api.computerUsePermissions.getStatus()
// 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
})
expect(permissionsStatus.helperUnavailableReason).toBeNull()
expect(permissionsStatus.permissions).toContainEqual({ id: 'accessibility', status: 'granted' })
await expect(