diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 2f5800e3dfa..0ed9669a2a3 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -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) diff --git a/src/main/computer/macos-computer-use-permissions.test.ts b/src/main/computer/macos-computer-use-permissions.test.ts index f2f469cc215..8345965c056 100644 --- a/src/main/computer/macos-computer-use-permissions.test.ts +++ b/src/main/computer/macos-computer-use-permissions.test.ts @@ -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) - vi.mocked(existsSync).mockReturnValue(true) - vi.mocked(readFileSync).mockReturnValue(json) + vi.mocked(execFileSync).mockReturnValue(json) } function setPlatform(platform: NodeJS.Platform): void { diff --git a/src/main/computer/macos-computer-use-permissions.ts b/src/main/computer/macos-computer-use-permissions.ts index 244683f5400..3de460eb29b 100644 --- a/src/main/computer/macos-computer-use-permissions.ts +++ b/src/main/computer/macos-computer-use-permissions.ts @@ -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> { - 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 - > - } 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> } function nextPermissionStep( diff --git a/src/main/computer/macos-native-provider-client.ts b/src/main/computer/macos-native-provider-client.ts index 6f4bf751b78..c55a824e041 100644 --- a/src/main/computer/macos-native-provider-client.ts +++ b/src/main/computer/macos-native-provider-client.ts @@ -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 { 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((resolve, reject) => { @@ -158,43 +161,35 @@ export class MacOSNativeProviderClient { `native macOS provider does not support ${String(group)}.${capability}` ) } - private async ensureSocketStarted(helperAppPath: string): Promise { + private async ensureSocketStarted(helperExecutablePath: string): Promise { 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 { + private async startSocket(helperExecutablePath: string): Promise { 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')