From 7323f463262172cdcf46cd66e64e41b12063b358 Mon Sep 17 00:00:00 2001 From: Sangin Date: Wed, 7 Oct 2026 10:57:32 +0900 Subject: [PATCH] fix(computer-use): reap permission status helpers a previous run left behind A permission check starts its helper with `open -n`, so a helper that wedges outlives the timeout and keeps its LaunchServices registration. At startup, SIGKILL this user's helpers whose status directory no longer exists. A live directory means a check is still in flight, possibly in another Orca instance, so those helpers are left alone. --- ...mputer-use-permission-helper-sweep.test.ts | 72 +++++++++++++++++++ ...os-computer-use-permission-helper-sweep.ts | 67 +++++++++++++++++ .../startup/main-process-ready-runtime.ts | 3 + 3 files changed, 142 insertions(+) create mode 100644 src/main/computer/macos-computer-use-permission-helper-sweep.test.ts create mode 100644 src/main/computer/macos-computer-use-permission-helper-sweep.ts diff --git a/src/main/computer/macos-computer-use-permission-helper-sweep.test.ts b/src/main/computer/macos-computer-use-permission-helper-sweep.test.ts new file mode 100644 index 000000000000..3801f15998a3 --- /dev/null +++ b/src/main/computer/macos-computer-use-permission-helper-sweep.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const runProcessMock = vi.hoisted(() => vi.fn()) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) + +import { + findStalePermissionStatusHelperPids, + sweepStalePermissionStatusHelpers +} from './macos-computer-use-permission-helper-sweep' + +const HELPER = '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos' +const dir = (id: string): string => `/var/folders/xx/T/orca-computer-use-permissions-${id}` +const line = (pid: number, id: string): string => + `${pid} ${HELPER} --permission-status-file ${dir(id)}/status.json` + +describe('findStalePermissionStatusHelperPids', () => { + it('selects only helpers whose status directory is gone', () => { + const live = new Set([dir('live')]) + const ps = [line(101, 'gone'), line(102, 'live'), line(103, 'gone2')].join('\n') + expect(findStalePermissionStatusHelperPids(ps, (d) => live.has(d))).toEqual([101, 103]) + }) + + it('leaves an in-flight check of another Orca instance alone', () => { + const ps = line(200, 'other-profile') + expect(findStalePermissionStatusHelperPids(ps, () => true)).toEqual([]) + }) + + it('ignores setup helpers, unrelated processes and foreign status paths', () => { + const ps = [ + `300 ${HELPER} --permission`, + `301 ${HELPER} --permissions`, + `302 /usr/bin/other --permission-status-file ${dir('a')}/status.json`, + `303 ${HELPER} --permission-status-file /tmp/user-file/status.json`, + `304 ${HELPER} --permission-status-file ${dir('a')}/other.json`, + 'garbage line' + ].join('\n') + expect(findStalePermissionStatusHelperPids(ps, () => false)).toEqual([]) + }) +}) + +describe('sweepStalePermissionStatusHelpers', () => { + const originalPlatform = process.platform + beforeEach(() => { + runProcessMock.mockReset() + Object.defineProperty(process, 'platform', { value: originalPlatform }) + }) + + it('does nothing off macOS', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + await sweepStalePermissionStatusHelpers() + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('lists only the current user and SIGKILLs stale helpers', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + runProcessMock.mockResolvedValue({ code: 0, stdout: line(555, 'gone'), timedOut: false }) + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) + const killed = await sweepStalePermissionStatusHelpers() + expect(runProcessMock.mock.calls[0][0].args).toEqual( + expect.arrayContaining(['-U', String(process.getuid?.())]) + ) + expect(kill).toHaveBeenCalledWith(555, 'SIGKILL') + expect(killed).toEqual([555]) + kill.mockRestore() + }) + + it('swallows ps failures', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + runProcessMock.mockRejectedValue(new Error('spawn ps ENOENT')) + await expect(sweepStalePermissionStatusHelpers()).resolves.toEqual([]) + }) +}) diff --git a/src/main/computer/macos-computer-use-permission-helper-sweep.ts b/src/main/computer/macos-computer-use-permission-helper-sweep.ts new file mode 100644 index 000000000000..283a1e81173a --- /dev/null +++ b/src/main/computer/macos-computer-use-permission-helper-sweep.ts @@ -0,0 +1,67 @@ +import { existsSync } from 'node:fs' +import { basename, dirname } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' + +const SWEEP_TIMEOUT_MS = 5_000 +const SWEEP_MAX_OUTPUT_BYTES = 4 * 1024 * 1024 +const STATUS_DIR_PREFIX = 'orca-computer-use-permissions-' +const STATUS_ARG = '/orca-computer-use-macos --permission-status-file ' + +// Why: the status directory is deleted when a check ends, so a helper whose directory is gone +// belongs to a finished check of any Orca instance; a live directory is an in-flight check. +export function findStalePermissionStatusHelperPids( + psOutput: string, + statusDirExists: (dir: string) => boolean +): number[] { + const pids: number[] = [] + for (const line of psOutput.split('\n')) { + const match = /^\s*(\d+)\s+(.*)$/.exec(line) + const argIndex = match ? match[2].indexOf(STATUS_ARG) : -1 + if (!match || argIndex < 0) { + continue + } + const statusPath = match[2].slice(argIndex + STATUS_ARG.length).trim() + const dir = dirname(statusPath) + if (basename(statusPath) !== 'status.json' || !basename(dir).startsWith(STATUS_DIR_PREFIX)) { + continue + } + if (!statusDirExists(dir)) { + pids.push(Number(match[1])) + } + } + return pids +} + +// Why: `open -n` detaches the helper, so a wedged one outlives the timeout that killed `open` +// and each leaked instance keeps a LaunchServices registration. +export async function sweepStalePermissionStatusHelpers(): Promise { + const uid = process.getuid?.() + if (process.platform !== 'darwin' || uid === undefined) { + return [] + } + let stdout: string + try { + const result = await runProcess({ + program: '/bin/ps', + args: ['-ww', '-U', String(uid), '-o', 'pid=', '-o', 'command='], + timeoutMs: SWEEP_TIMEOUT_MS, + maxOutputBytes: SWEEP_MAX_OUTPUT_BYTES + }) + if (result.timedOut) { + return [] + } + stdout = result.stdout + } catch { + return [] + } + const killed: number[] = [] + for (const pid of findStalePermissionStatusHelperPids(stdout, existsSync)) { + try { + process.kill(pid, 'SIGKILL') + killed.push(pid) + } catch { + // Already exited. + } + } + return killed +} diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index f06bb04177ca..f1bc7c937c60 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -36,6 +36,7 @@ import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from '../work import { loadWorktreeRemovalRecordsForStore } from './worktree-removal-records-load' import { runAfterFirstWindowShown } from './first-window-deferral' import { logStartupMilestone } from './startup-diagnostics' +import { sweepStalePermissionStatusHelpers } from '../computer/macos-computer-use-permission-helper-sweep' import { refreshInstalledOpenCodeStatusPlugins } from '../opencode/opencode-status-plugin-startup-refresh' // Headless serve never opens a window, so the sweep still has to run off a timer there. @@ -63,6 +64,8 @@ export async function initializeReadyRuntimeServices(): Promise { runtime.setAgentBrowserBridge(state.agentBrowserBridge) // Why: daemons a crashed or SIGKILL'd previous run left behind answer to nobody; nothing else reclaims them. void state.agentBrowserBridge.sweepOrphanedSessions() + // Why: permission-check helpers a previous run left wedged stay registered with LaunchServices until killed. + void sweepStalePermissionStatusHelpers() const browserClientAutomationDispatcher = new RpcDispatcher({ runtime }) configureBrowserClientPageAutomationRuntime({ browserManager,