Fix macOS computer-use helper permission checks (#1705)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
daaea6acaa
commit
ff7de93928
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue