Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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([])
})
})
67 changes: 67 additions & 0 deletions src/main/computer/macos-computer-use-permission-helper-sweep.ts
Original file line number Diff line number Diff line change
@@ -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<number[]> {
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
}
3 changes: 3 additions & 0 deletions src/main/startup/main-process-ready-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -63,6 +64,8 @@ export async function initializeReadyRuntimeServices(): Promise<void> {
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,
Expand Down