Fix macOS computer-use helper permission checks (#1705)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-11 20:31:11 -04:00 committed by GitHub
parent daaea6acaa
commit ff7de93928
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 51 additions and 89 deletions

View File

@ -2592,6 +2592,8 @@ private final class PermissionDragAssistantView: NSView {
private final class DraggableAppTile: NSView, NSDraggingSource {
private let appURL: URL
override var mouseDownCanMoveWindow: Bool { false }
init(appURL: URL) {
self.appURL = appURL
super.init(frame: .zero)

View File

@ -1,28 +1,19 @@
import { spawn, spawnSync } from 'child_process'
import { existsSync, readFileSync, rmSync } from 'fs'
import type * as Fs from 'fs'
import { execFileSync, spawn, spawnSync } from 'child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { openComputerUsePermissions } from './macos-computer-use-permissions'
const resolveHelperAppPathMock = vi.hoisted(() => vi.fn())
const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn())
vi.mock('child_process', () => ({
execFileSync: vi.fn(),
spawn: vi.fn(() => ({ unref: vi.fn() })),
spawnSync: vi.fn()
}))
vi.mock('fs', async (importOriginal) => {
const actual = (await importOriginal()) as typeof Fs
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
rmSync: vi.fn()
}
})
vi.mock('./macos-native-provider-paths', () => ({
resolveMacOSComputerUseAppPath: resolveHelperAppPathMock
resolveMacOSComputerUseAppPath: resolveHelperAppPathMock,
resolveMacOSComputerUseExecutablePath: resolveHelperExecutablePathMock
}))
describe('openComputerUsePermissions', () => {
@ -31,10 +22,12 @@ describe('openComputerUsePermissions', () => {
beforeEach(() => {
vi.mocked(spawn).mockClear()
vi.mocked(spawnSync).mockClear()
vi.mocked(existsSync).mockReset()
vi.mocked(readFileSync).mockReset()
vi.mocked(rmSync).mockReset()
vi.mocked(execFileSync).mockReset()
resolveHelperAppPathMock.mockReset()
resolveHelperExecutablePathMock.mockReset()
resolveHelperExecutablePathMock.mockReturnValue(
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos'
)
mockPermissionStatus('{"accessibility":"granted","screenshots":"granted"}')
setPlatform('darwin')
})
@ -142,7 +135,7 @@ describe('openComputerUsePermissions', () => {
expect(() => openComputerUsePermissions()).toThrow('Orca Computer Use.app was not found')
})
it('reads permission status through the helper app bundle', async () => {
it('reads permission status through the helper app executable', async () => {
const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}')
@ -154,25 +147,17 @@ describe('openComputerUsePermissions', () => {
{ id: 'screenshots', status: 'not-granted' }
]
})
expect(spawnSync).toHaveBeenCalledWith(
'/usr/bin/open',
[
'-n',
'/Applications/Orca Computer Use.app',
'--args',
'--permission-status-file',
expect.stringContaining('/status.json')
],
{ stdio: 'ignore' }
expect(execFileSync).toHaveBeenCalledWith(
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos',
['--permission-status'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
)
expect(rmSync).toHaveBeenCalled()
})
})
function mockPermissionStatus(json: string): void {
vi.mocked(spawnSync).mockReturnValue({} as ReturnType<typeof spawnSync>)
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(readFileSync).mockReturnValue(json)
vi.mocked(execFileSync).mockReturnValue(json)
}
function setPlatform(platform: NodeJS.Platform): void {

View File

@ -1,9 +1,9 @@
import { spawn, spawnSync } from 'child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { execFileSync, spawn, spawnSync } from 'child_process'
import { RuntimeClientError } from './runtime-client-error'
import { resolveMacOSComputerUseAppPath } from './macos-native-provider-paths'
import {
resolveMacOSComputerUseAppPath,
resolveMacOSComputerUseExecutablePath
} from './macos-native-provider-paths'
import type {
ComputerUsePermissionId,
ComputerUsePermissionSetupResult,
@ -106,40 +106,20 @@ export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusRes
function readPermissionStatusFromHelperApp(
helperAppPath: string
): Partial<Record<ComputerUsePermissionId, ComputerUsePermissionStatus>> {
const directory = mkdtempSync(join(tmpdir(), 'orca-computer-permissions-'))
const statusPath = join(directory, 'status.json')
try {
// Why: TCC can attribute direct executable probes to the parent shell;
// launch the app bundle so status uses the same identity as real actions.
const result = spawnSync(
'/usr/bin/open',
['-n', helperAppPath, '--args', '--permission-status-file', statusPath],
{ stdio: 'ignore' }
const executablePath = resolveMacOSComputerUseExecutablePath()
if (!executablePath) {
throw new RuntimeClientError(
'accessibility_error',
`${helperAppPath}/Contents/MacOS/orca-computer-use-macos was not found`
)
if (result.error) {
throw result.error
}
waitForStatusFile(statusPath)
return JSON.parse(readFileSync(statusPath, 'utf8')) as Partial<
Record<ComputerUsePermissionId, ComputerUsePermissionStatus>
>
} finally {
rmSync(directory, { force: true, recursive: true })
}
}
function waitForStatusFile(statusPath: string): void {
const deadline = Date.now() + 3_000
while (Date.now() < deadline) {
if (existsSync(statusPath)) {
return
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
}
throw new RuntimeClientError(
'action_timeout',
'Orca Computer Use.app did not report permission status'
)
// Why: launching the nested helper via LaunchServices can make TCC evaluate
// Orca.app as responsible; the signed helper executable owns this grant.
const output = execFileSync(executablePath, ['--permission-status'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
return JSON.parse(output) as Partial<Record<ComputerUsePermissionId, ComputerUsePermissionStatus>>
}
function nextPermissionStep(

View File

@ -21,7 +21,10 @@ import {
type PendingNativeRequest,
writeNativeProviderLine
} from './macos-native-provider-contract'
import { resolveMacOSComputerUseAppPath } from './macos-native-provider-paths'
import {
resolveMacOSComputerUseAppPath,
resolveMacOSComputerUseExecutablePath
} from './macos-native-provider-paths'
import { connectMacOSProviderSocket } from './macos-native-provider-socket'
import { RuntimeClientError } from './runtime-client-error'
@ -90,11 +93,11 @@ export class MacOSNativeProviderClient {
}
private async send(method: NativeMethod, params: unknown): Promise<unknown> {
const id = this.nextId++
const helperAppPath = resolveMacOSComputerUseAppPath()
if (!helperAppPath) {
const helperExecutablePath = resolveMacOSComputerUseExecutablePath()
if (!helperExecutablePath) {
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
const transport = await this.ensureSocketStarted(helperAppPath)
const transport = await this.ensureSocketStarted(helperExecutablePath)
const token = this.socketToken
const line = `${JSON.stringify({ id, method, params, token })}\n`
const result = new Promise<unknown>((resolve, reject) => {
@ -158,43 +161,35 @@ export class MacOSNativeProviderClient {
`native macOS provider does not support ${String(group)}.${capability}`
)
}
private async ensureSocketStarted(helperAppPath: string): Promise<net.Socket> {
private async ensureSocketStarted(helperExecutablePath: string): Promise<net.Socket> {
if (this.socket && !this.socket.destroyed) {
return this.socket
}
if (this.socketStartPromise) {
return await this.socketStartPromise
}
this.socketStartPromise = this.startSocket(helperAppPath)
this.socketStartPromise = this.startSocket(helperExecutablePath)
try {
return await this.socketStartPromise
} finally {
this.socketStartPromise = null
}
}
private async startSocket(helperAppPath: string): Promise<net.Socket> {
private async startSocket(helperExecutablePath: string): Promise<net.Socket> {
this.socketDirectory = mkdtempSync(join(tmpdir(), 'orca-computer-use-'))
chmodSync(this.socketDirectory, 0o700)
this.socketPath = join(this.socketDirectory, 'provider.sock')
this.socketToken = randomUUID()
this.socketTokenPath = join(this.socketDirectory, 'provider.token')
writeFileSync(this.socketTokenPath, this.socketToken, { encoding: 'utf8', mode: 0o600 })
// Why: macOS TCC attaches Accessibility/Screen Recording to the helper
// app bundle; direct child-process stdio cannot reliably read AX windows.
const opener = spawn(
'/usr/bin/open',
[
'-n',
helperAppPath,
'--args',
'--agent',
this.socketPath,
'--token-file',
this.socketTokenPath
],
// Why: launching the nested helper via LaunchServices can make TCC evaluate
// Orca.app as responsible; the signed helper executable owns this grant.
const provider = spawn(
helperExecutablePath,
['--agent', this.socketPath, '--token-file', this.socketTokenPath],
{ detached: true, stdio: 'ignore' }
)
opener.unref()
provider.unref()
try {
const socket = await connectMacOSProviderSocket(this.socketPath, HELPER_CONNECT_TIMEOUT_MS)
socket.setEncoding('utf8')