From 53e80ef9d4874f1cbf434f84aa569ac2d1d653fc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:55:44 -0700 Subject: [PATCH] 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. --- ...omputer-use-permission-status-gate.test.ts | 169 ++++++++++++++++ ...cos-computer-use-permission-status-gate.ts | 186 ++++++++++++++++++ src/main/computer/sidecar-client.ts | 5 + src/main/ipc/computer-use-permissions.test.ts | 69 +++++++ src/main/ipc/computer-use-permissions.ts | 30 ++- src/main/runtime/rpc/methods/computer.ts | 9 +- .../api/computer-use-permissions-bridge.ts | 3 +- src/preload/api/os-permission-api.ts | 3 +- .../agent-capability-setup-status.ts | 2 +- .../setup-guide/use-setup-guide-progress.ts | 4 +- .../preload-api/web-host-capability-api.ts | 1 + .../src/web/web-preload-api-ui.test.ts | 5 +- 12 files changed, 471 insertions(+), 15 deletions(-) create mode 100644 src/main/computer/macos-computer-use-permission-status-gate.test.ts create mode 100644 src/main/computer/macos-computer-use-permission-status-gate.ts create mode 100644 src/main/ipc/computer-use-permissions.test.ts diff --git a/src/main/computer/macos-computer-use-permission-status-gate.test.ts b/src/main/computer/macos-computer-use-permission-status-gate.test.ts new file mode 100644 index 00000000000..9bb57c9ad55 --- /dev/null +++ b/src/main/computer/macos-computer-use-permission-status-gate.test.ts @@ -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(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => {} + const promise = new Promise((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() + 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) + }) +}) diff --git a/src/main/computer/macos-computer-use-permission-status-gate.ts b/src/main/computer/macos-computer-use-permission-status-gate.ts new file mode 100644 index 00000000000..2ff62be0363 --- /dev/null +++ b/src/main/computer/macos-computer-use-permission-status-gate.ts @@ -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 + followUp: Promise | 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 + 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 + 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 => { + 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 => { + // 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() + } +}) diff --git a/src/main/computer/sidecar-client.ts b/src/main/computer/sidecar-client.ts index 98ba74d6091..57fd6689857 100644 --- a/src/main/computer/sidecar-client.ts +++ b/src/main/computer/sidecar-client.ts @@ -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)) } diff --git a/src/main/ipc/computer-use-permissions.test.ts b/src/main/ipc/computer-use-permissions.test.ts new file mode 100644 index 00000000000..5c9c985014f --- /dev/null +++ b/src/main/ipc/computer-use-permissions.test.ts @@ -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 unknown> + focusedWindow: { current: object | null } + probe: ReturnType +} + +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) + }) +}) diff --git a/src/main/ipc/computer-use-permissions.ts b/src/main/ipc/computer-use-permissions.ts index af6d19d68b3..cd97f865bb9 100644 --- a/src/main/ipc/computer-use-permissions.ts +++ b/src/main/ipc/computer-use-permissions.ts @@ -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 => { const { openComputerUsePermissions } = await import('../computer/macos-computer-use-permissions') + computerUsePermissionStatusGate.invalidate() return openComputerUsePermissions(args?.id) } ) ipcMain.handle( 'computerUsePermissions:getStatus', - async (): Promise => { - const { getComputerUsePermissionStatus } = - await import('../computer/macos-computer-use-permissions') - return getComputerUsePermissionStatus() - } + (_event, args?: PermissionStatusReadOptions): Promise => + computerUsePermissionStatusGate.read({ background: args?.background === true }) ) ipcMain.handle( 'computerUsePermissions:reset', async (): Promise => { 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() + } } ) } diff --git a/src/main/runtime/rpc/methods/computer.ts b/src/main/runtime/rpc/methods/computer.ts index b9f3402d9d6..12219d8995c 100644 --- a/src/main/runtime/rpc/methods/computer.ts +++ b/src/main/runtime/rpc/methods/computer.ts @@ -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({ diff --git a/src/preload/api/computer-use-permissions-bridge.ts b/src/preload/api/computer-use-permissions-bridge.ts index bd36efbdcc3..586b66a5b16 100644 --- a/src/preload/api/computer-use-permissions-bridge.ts +++ b/src/preload/api/computer-use-permissions-bridge.ts @@ -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') diff --git a/src/preload/api/os-permission-api.ts b/src/preload/api/os-permission-api.ts index ddf44beac23..9b3a3cbaea6 100644 --- a/src/preload/api/os-permission-api.ts +++ b/src/preload/api/os-permission-api.ts @@ -61,7 +61,8 @@ export type DeveloperPermissionsApi = { } export type ComputerUsePermissionsApi = { - getStatus: () => Promise + // Passive badges pass background to accept a backed-off denial instead of relaunching the helper. + getStatus: (args?: { background?: boolean }) => Promise openSetup: (args?: { id?: ComputerUsePermissionId }) => Promise reset: () => Promise } diff --git a/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts b/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts index 8de29e771b3..6c521293fd5 100644 --- a/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts +++ b/src/renderer/src/components/feature-wall/agent-capability-setup-status.ts @@ -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 diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts index 5d82f43ab90..8c5590b6689 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts @@ -191,7 +191,9 @@ export function useSetupGuideProgress( }, [orderedGitRepos, settings, setupScriptProbeSignature, shouldRefreshCoreState]) const readComputerUsePermissions = useCallback(async (isStale: () => boolean): Promise => { - const status = await window.api.computerUsePermissions.getStatus().catch(() => null) + const status = await window.api.computerUsePermissions + .getStatus({ background: true }) + .catch(() => null) if (isStale()) { return } diff --git a/src/renderer/src/web/preload-api/web-host-capability-api.ts b/src/renderer/src/web/preload-api/web-host-capability-api.ts index 2cb671ec250..0d36535fa48 100644 --- a/src/renderer/src/web/preload-api/web-host-capability-api.ts +++ b/src/renderer/src/web/preload-api/web-host-capability-api.ts @@ -114,6 +114,7 @@ export function createComputerUsePermissionsApi(): NonNullable< Partial['computerUsePermissions'] > { return { + // Browser focus can't activate the host app, so remote reads never opt into the denied backoff. getStatus: () => callRuntimeResult( 'computer.permissionsStatus', diff --git a/src/renderer/src/web/web-preload-api-ui.test.ts b/src/renderer/src/web/web-preload-api-ui.test.ts index db5f72ac4d2..da86ee94300 100644 --- a/src/renderer/src/web/web-preload-api-ui.test.ts +++ b/src/renderer/src/web/web-preload-api-ui.test.ts @@ -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(