From 3a40c62169822c21ee263914a10fd00a2053220a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 25 May 2026 17:54:47 -0400 Subject: [PATCH] Improve Computer Use permission setup (#2758) Co-authored-by: Orca --- .../Sources/OrcaComputerUseMacOS/main.swift | 279 ++++++++++++++---- .../macos-computer-use-permissions.test.ts | 189 ++++++++++-- .../macos-computer-use-permissions.ts | 196 ++++++++++-- src/main/ipc/computer-use-permissions.ts | 9 + src/preload/api-types.ts | 2 + src/preload/index.ts | 3 +- .../settings/AgentSkillSetupPanel.tsx | 4 +- .../components/settings/ComputerUsePane.tsx | 204 +++++++++---- src/renderer/src/web/web-preload-api.ts | 8 + src/shared/computer-use-permissions-types.ts | 4 + 10 files changed, 746 insertions(+), 152 deletions(-) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 2dd0c7e4a..9b6a92e4c 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -2056,6 +2056,7 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { windowController = PermissionWindowController( + initialPermission: initialPermission, terminateWhenDragAssistantCloses: initialPermission != nil ) if let initialPermission { @@ -2067,6 +2068,10 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate { } } + func applicationDidBecomeActive(_ notification: Notification) { + windowController?.refreshPermissions() + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { initialPermission == nil } @@ -2074,9 +2079,11 @@ private final class PermissionRuntime: NSObject, NSApplicationDelegate { private final class PermissionWindowController: NSWindowController { private var dragAssistant: PermissionDragAssistantController? + private var dragAssistantPermission: PermissionKind? + private let initialPermission: PermissionKind? private let terminateWhenDragAssistantCloses: Bool - convenience init(terminateWhenDragAssistantCloses: Bool = false) { + convenience init(initialPermission: PermissionKind? = nil, terminateWhenDragAssistantCloses: Bool = false) { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 300, height: 315), styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], @@ -2089,13 +2096,20 @@ private final class PermissionWindowController: NSWindowController { window.backgroundColor = PermissionPalette.background window.center() window.isReleasedWhenClosed = false - self.init(window: window, terminateWhenDragAssistantCloses: terminateWhenDragAssistantCloses) - window.contentView = PermissionView(frame: window.contentView?.bounds ?? .zero) { [weak self] permission in - self?.showDragAssistant(for: permission) - } + self.init(window: window, initialPermission: initialPermission, terminateWhenDragAssistantCloses: terminateWhenDragAssistantCloses) + window.contentView = PermissionView( + frame: window.contentView?.bounds ?? .zero, + showDragAssistant: { [weak self] permission in + self?.showDragAssistant(for: permission) + }, + close: { [weak self] in + self?.closePermissionWindow() + } + ) } - init(window: NSWindow?, terminateWhenDragAssistantCloses: Bool) { + init(window: NSWindow?, initialPermission: PermissionKind?, terminateWhenDragAssistantCloses: Bool) { + self.initialPermission = initialPermission self.terminateWhenDragAssistantCloses = terminateWhenDragAssistantCloses super.init(window: window) } @@ -2106,9 +2120,13 @@ private final class PermissionWindowController: NSWindowController { private func showDragAssistant(for permission: PermissionKind) { dragAssistant?.close() + dragAssistantPermission = permission dragAssistant = PermissionDragAssistantController( permission: permission, fallbackVisibleFrame: window?.screen?.visibleFrame, + onRefreshPermissions: { [weak self] in + self?.refreshPermissions() + }, onClose: { [weak self] in if self?.terminateWhenDragAssistantCloses == true { NSApp.terminate(nil) @@ -2118,13 +2136,57 @@ private final class PermissionWindowController: NSWindowController { dragAssistant?.showWhenReady() } + private func closeDragAssistant() { + dragAssistant?.close() + dragAssistant = nil + dragAssistantPermission = nil + } + + private func completeDragAssistant() { + guard let dragAssistant else { + dragAssistantPermission = nil + if terminateWhenDragAssistantCloses { + NSApp.terminate(nil) + } + return + } + self.dragAssistant = nil + dragAssistantPermission = nil + dragAssistant.complete() + } + + private func closePermissionWindow() { + // Why: the floating assistant is a separate retained window controller and + // can keep the helper app alive after the main permission window closes. + closeDragAssistant() + window?.close() + } + func openPermission(_ permission: PermissionKind) { permission.requestAndOpenSettings() showDragAssistant(for: permission) } + + func refreshPermissions() { + if let initialPermission, initialPermission.isGranted { + // Why: targeted permission helpers should finish once the requested + // grant lands, even if other Computer Use permissions remain unset. + completeDragAssistant() + return + } + if dragAssistantPermission?.isGranted == true { + // Why: after one grant in full setup, the remaining missing permission + // needs fresh guidance instead of the old assistant's instructions. + closeDragAssistant() + } + if PermissionKind.allCases.allSatisfy(\.isGranted) { + closeDragAssistant() + } + (window?.contentView as? PermissionView)?.refreshPermissions() + } } -private enum PermissionKind { +private enum PermissionKind: CaseIterable { case accessibility case screenshots @@ -2148,6 +2210,42 @@ private enum PermissionKind { } } + var title: String { + switch self { + case .accessibility: + "Accessibility" + case .screenshots: + "Screenshots" + } + } + + var detail: String { + switch self { + case .accessibility: + "Read and control app interfaces" + case .screenshots: + "Capture windows for visual state" + } + } + + var icon: NSImage { + switch self { + case .accessibility: + NSImage(systemSymbolName: "figure", accessibilityDescription: "Accessibility") ?? NSImage() + case .screenshots: + NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "Screen Recording") ?? NSImage() + } + } + + var isGranted: Bool { + switch self { + case .accessibility: + accessibilityTrusted() + case .screenshots: + screenCaptureTrusted() + } + } + func requestAndOpenSettings() { switch self { case .accessibility: @@ -2162,9 +2260,13 @@ private enum PermissionKind { private final class PermissionView: NSView { private let appURL = Bundle.main.bundleURL private let showDragAssistant: (PermissionKind) -> Void + private let close: () -> Void + private var contentStack: NSStackView? + private var contentConstraints: [NSLayoutConstraint] = [] - init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void) { + init(frame frameRect: NSRect, showDragAssistant: @escaping (PermissionKind) -> Void, close: @escaping () -> Void) { self.showDragAssistant = showDragAssistant + self.close = close super.init(frame: frameRect) wantsLayer = true layer?.backgroundColor = PermissionPalette.background.cgColor @@ -2176,6 +2278,10 @@ private final class PermissionView: NSView { } private func build() { + NSLayoutConstraint.deactivate(contentConstraints) + contentStack?.removeFromSuperview() + contentConstraints = [] + let stack = NSStackView() stack.orientation = .vertical stack.alignment = .leading @@ -2183,6 +2289,7 @@ private final class PermissionView: NSView { stack.distribution = .gravityAreas stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) + contentStack = stack let icon = NSImageView(image: NSWorkspace.shared.icon(forFile: appURL.path)) icon.imageScaling = .scaleProportionallyUpOrDown @@ -2192,9 +2299,12 @@ private final class PermissionView: NSView { icon.heightAnchor.constraint(equalToConstant: 58) ]) - let title = label("Enable Orca Computer Use", size: 22, weight: .bold) + let missingPermissions = PermissionKind.allCases.filter { !$0.isGranted } + let ready = missingPermissions.isEmpty + + let title = label(ready ? "Computer Use is Ready" : "Enable Orca Computer Use", size: 22, weight: .bold) let subtitle = label( - "Grant permissions so Orca can use apps when you ask.", + ready ? "Orca can use local apps when you ask." : "Grant permissions so Orca can use apps when you ask.", size: 12, weight: .regular ) @@ -2211,41 +2321,32 @@ private final class PermissionView: NSView { header.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true subtitle.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -10).isActive = true - stack.addArrangedSubview(permissionRow( - icon: NSImage(systemSymbolName: "figure", accessibilityDescription: "Accessibility"), - title: "Accessibility", - detail: "Read and control app interfaces", - buttonTitle: "Allow" - ) { - PermissionKind.accessibility.requestAndOpenSettings() - self.showDragAssistant(.accessibility) - }) + if ready { + stack.addArrangedSubview(doneButton()) + } else { + for permission in missingPermissions { + stack.addArrangedSubview(permissionRow(permission: permission) { [weak self] in + permission.requestAndOpenSettings() + self?.showDragAssistant(permission) + }) + } + } - stack.addArrangedSubview(permissionRow( - icon: NSImage(systemSymbolName: "camera.viewfinder", accessibilityDescription: "Screen Recording"), - title: "Screenshots", - detail: "Capture windows for visual state", - buttonTitle: "Allow" - ) { - PermissionKind.screenshots.requestAndOpenSettings() - self.showDragAssistant(.screenshots) - }) - - NSLayoutConstraint.activate([ + contentConstraints = [ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 18), stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -18), stack.topAnchor.constraint(equalTo: topAnchor, constant: 22), stack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -20) - ]) + ] + NSLayoutConstraint.activate(contentConstraints) } - private func permissionRow( - icon: NSImage?, - title: String, - detail: String, - buttonTitle: String, - action: @escaping () -> Void - ) -> NSView { + func refreshPermissions() { + // Why: TCC grants can change in System Settings while this window stays open. + build() + } + + private func permissionRow(permission: PermissionKind, action: @escaping () -> Void) -> NSView { let row = NSView() row.wantsLayer = true row.layer?.cornerRadius = 14 @@ -2254,13 +2355,13 @@ private final class PermissionView: NSView { row.layer?.backgroundColor = PermissionPalette.card.cgColor row.translatesAutoresizingMaskIntoConstraints = false - let iconView = NSImageView(image: icon ?? NSImage()) + let iconView = NSImageView(image: permission.icon) iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 30, weight: .regular) iconView.contentTintColor = .controlAccentColor iconView.translatesAutoresizingMaskIntoConstraints = false - let titleLabel = label(title, size: 13, weight: .bold) - let detailLabel = label(detail, size: 11, weight: .regular) + let titleLabel = label(permission.title, size: 13, weight: .bold) + let detailLabel = label(permission.detail, size: 11, weight: .regular) detailLabel.textColor = PermissionPalette.secondaryText let textStack = NSStackView(views: [titleLabel, detailLabel]) textStack.orientation = .vertical @@ -2268,7 +2369,7 @@ private final class PermissionView: NSView { textStack.spacing = 4 textStack.translatesAutoresizingMaskIntoConstraints = false - let button = NSButton(title: buttonTitle, target: nil, action: nil) + let button = NSButton(title: "Allow", target: nil, action: nil) button.bezelStyle = .rounded button.controlSize = .regular button.font = NSFont.systemFont(ofSize: 13, weight: .semibold) @@ -2278,8 +2379,8 @@ private final class PermissionView: NSView { .foregroundColor: NSColor.white, .font: NSFont.systemFont(ofSize: 13, weight: .semibold) ] - button.attributedTitle = NSAttributedString(string: buttonTitle, attributes: buttonTitleAttributes) - button.attributedAlternateTitle = NSAttributedString(string: buttonTitle, attributes: buttonTitleAttributes) + button.attributedTitle = NSAttributedString(string: "Allow", attributes: buttonTitleAttributes) + button.attributedAlternateTitle = NSAttributedString(string: "Allow", attributes: buttonTitleAttributes) let target = ButtonTarget(action) button.target = target button.action = #selector(ButtonTarget.run) @@ -2306,6 +2407,29 @@ private final class PermissionView: NSView { return row } + private func doneButton() -> NSView { + let button = NSButton(title: "Done", target: nil, action: nil) + button.bezelStyle = .rounded + button.controlSize = .regular + button.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + button.contentTintColor = .white + button.bezelColor = .controlAccentColor + let buttonTitleAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.white, + .font: NSFont.systemFont(ofSize: 13, weight: .semibold) + ] + button.attributedTitle = NSAttributedString(string: "Done", attributes: buttonTitleAttributes) + button.attributedAlternateTitle = NSAttributedString(string: "Done", attributes: buttonTitleAttributes) + let target = ButtonTarget(close) + button.target = target + button.action = #selector(ButtonTarget.run) + objc_setAssociatedObject(button, "orca-action", target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + button.translatesAutoresizingMaskIntoConstraints = false + button.widthAnchor.constraint(greaterThanOrEqualToConstant: 82).isActive = true + button.heightAnchor.constraint(equalToConstant: 32).isActive = true + return button + } + private func label(_ text: String, size: CGFloat, weight: NSFont.Weight) -> NSTextField { let label = NSTextField(labelWithString: text) label.font = NSFont.systemFont(ofSize: size, weight: weight) @@ -2323,11 +2447,19 @@ private final class PermissionDragAssistantController: NSWindowController { } private let fallbackVisibleFrame: NSRect? + private let onRefreshPermissions: () -> Void private let onClose: () -> Void private var hasSeenSettingsWindow = false private var followTimer: Timer? + private var isDismissed = false + private var scheduledShowWorkItems: [DispatchWorkItem] = [] - convenience init(permission: PermissionKind, fallbackVisibleFrame: NSRect?, onClose: @escaping () -> Void) { + convenience init( + permission: PermissionKind, + fallbackVisibleFrame: NSRect?, + onRefreshPermissions: @escaping () -> Void, + onClose: @escaping () -> Void + ) { let window = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 390, height: 92), styleMask: [.borderless, .nonactivatingPanel], @@ -2347,15 +2479,25 @@ private final class PermissionDragAssistantController: NSWindowController { window.isMovable = false window.isMovableByWindowBackground = false window.hasShadow = true - self.init(window: window, fallbackVisibleFrame: fallbackVisibleFrame, onClose: onClose) - window.contentView = PermissionDragAssistantView(permission: permission, appURL: Bundle.main.bundleURL) { [weak self, weak window] in - window?.close() - self?.onClose() + self.init( + window: window, + fallbackVisibleFrame: fallbackVisibleFrame, + onRefreshPermissions: onRefreshPermissions, + onClose: onClose + ) + window.contentView = PermissionDragAssistantView(permission: permission, appURL: Bundle.main.bundleURL) { [weak self] in + self?.dismissFromCloseButton() } } - init(window: NSWindow?, fallbackVisibleFrame: NSRect?, onClose: @escaping () -> Void) { + init( + window: NSWindow?, + fallbackVisibleFrame: NSRect?, + onRefreshPermissions: @escaping () -> Void, + onClose: @escaping () -> Void + ) { self.fallbackVisibleFrame = fallbackVisibleFrame + self.onRefreshPermissions = onRefreshPermissions self.onClose = onClose super.init(window: window) } @@ -2365,31 +2507,52 @@ private final class PermissionDragAssistantController: NSWindowController { } func showWhenReady() { + guard !isDismissed else { return } startFollowingSettingsWindow() schedulePositionAndShow() } override func close() { + isDismissed = true + scheduledShowWorkItems.forEach { $0.cancel() } + scheduledShowWorkItems.removeAll() followTimer?.invalidate() followTimer = nil super.close() } + private func dismissFromCloseButton() { + // Why: closing the NSWindow directly skips this controller's timer cleanup, + // letting the assistant reappear while System Settings remains visible. + complete() + } + + func complete() { + close() + onClose() + } + private func schedulePositionAndShow() { + scheduledShowWorkItems.forEach { $0.cancel() } + scheduledShowWorkItems.removeAll() let delays = [0.12, 0.25, 0.4, 0.65, 0.95, 1.35, 1.8, 2.5] for (index, delay) in delays.enumerated() { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, self.window?.isVisible != true else { return } + let workItem = DispatchWorkItem { [weak self] in + guard let self, !self.isDismissed, self.window?.isVisible != true else { return } if let settingsWindow = self.systemSettingsWindowState(), settingsWindow.isVisible { self.positionNearSettingsWindow(settingsWindow.frame) + guard !self.isDismissed else { return } self.showWindow(nil) self.window?.orderFrontRegardless() } else if index == delays.count - 1 && self.systemSettingsIsFrontmost() { self.positionFallback() + guard !self.isDismissed else { return } self.showWindow(nil) self.window?.orderFrontRegardless() } } + scheduledShowWorkItems.append(workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } } @@ -2397,12 +2560,18 @@ private final class PermissionDragAssistantController: NSWindowController { followTimer?.invalidate() followTimer = Timer.scheduledTimer(withTimeInterval: 0.35, repeats: true) { [weak self] _ in Task { @MainActor in - self?.syncVisibilityWithSettingsWindow() + guard let self, !self.isDismissed else { return } + self.syncVisibilityWithSettingsWindow() } } } private func syncVisibilityWithSettingsWindow() { + guard !isDismissed else { + followTimer?.invalidate() + followTimer = nil + return + } guard let window else { followTimer?.invalidate() followTimer = nil @@ -2418,10 +2587,14 @@ private final class PermissionDragAssistantController: NSWindowController { // Why: System Settings can stay visible on one display while the user // works on another; follow actual occlusion instead of app focus. if let settingsWindow, settingsWindow.isVisible { + onRefreshPermissions() + guard !isDismissed else { return } positionNearSettingsWindow(settingsWindow.frame) + guard !isDismissed else { return } if !window.isVisible { showWindow(nil) } + guard !isDismissed else { return } window.orderFrontRegardless() } else if window.isVisible { window.orderOut(nil) diff --git a/src/main/computer/macos-computer-use-permissions.test.ts b/src/main/computer/macos-computer-use-permissions.test.ts index 2f78b3cab..d2c388560 100644 --- a/src/main/computer/macos-computer-use-permissions.test.ts +++ b/src/main/computer/macos-computer-use-permissions.test.ts @@ -1,16 +1,40 @@ import { execFileSync, spawn, spawnSync } from 'child_process' +import { mkdtemp, readFile, rm, stat } from 'fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { openComputerUsePermissions } from './macos-computer-use-permissions' +import { + openComputerUsePermissions, + resetComputerUsePermissions +} 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() })), + spawn: vi.fn(() => { + const child = { + stdout: { on: vi.fn(), setEncoding: vi.fn() }, + stderr: { on: vi.fn(), setEncoding: vi.fn() }, + on: vi.fn((event: string, callback: (status: number) => void) => { + if (event === 'close') { + queueMicrotask(() => callback(0)) + } + return child + }), + unref: vi.fn() + } + return child + }), spawnSync: vi.fn() })) +vi.mock('fs/promises', () => ({ + mkdtemp: vi.fn(), + readFile: vi.fn(), + rm: vi.fn(), + stat: vi.fn() +})) + vi.mock('./macos-native-provider-paths', () => ({ resolveMacOSComputerUseAppPath: resolveHelperAppPathMock, resolveMacOSComputerUseExecutablePath: resolveHelperExecutablePathMock @@ -23,11 +47,17 @@ describe('openComputerUsePermissions', () => { vi.mocked(spawn).mockClear() vi.mocked(spawnSync).mockClear() vi.mocked(execFileSync).mockReset() + vi.mocked(mkdtemp).mockReset() + vi.mocked(readFile).mockReset() + vi.mocked(rm).mockReset() + vi.mocked(stat).mockReset() resolveHelperAppPathMock.mockReset() resolveHelperExecutablePathMock.mockReset() resolveHelperExecutablePathMock.mockReturnValue( '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos' ) + vi.mocked(mkdtemp).mockResolvedValue('/tmp/orca-computer-use-permissions-test') + vi.mocked(stat).mockResolvedValue({} as Awaited>) mockPermissionStatus('{"accessibility":"granted","screenshots":"granted"}') setPlatform('darwin') }) @@ -36,10 +66,10 @@ describe('openComputerUsePermissions', () => { setPlatform(originalPlatform) }) - it('does not launch the setup helper when all permissions are granted', () => { + it('does not launch the setup helper when all permissions are granted', async () => { resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') - expect(openComputerUsePermissions()).toEqual({ + await expect(openComputerUsePermissions()).resolves.toEqual({ platform: 'darwin', helperAppPath: '/Applications/Orca Computer Use.app', permissionId: undefined, @@ -51,14 +81,18 @@ describe('openComputerUsePermissions', () => { ], nextStep: null }) - expect(spawn).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalledWith( + '/usr/bin/open', + ['-n', '/Applications/Orca Computer Use.app', '--args', '--permissions'], + { detached: true, stdio: 'ignore' } + ) }) - it('launches the helper app in permissions mode', () => { + it('launches the helper app in permissions mode', async () => { resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}') - expect(openComputerUsePermissions()).toEqual({ + await expect(openComputerUsePermissions()).resolves.toEqual({ platform: 'darwin', helperAppPath: '/Applications/Orca Computer Use.app', permissionId: undefined, @@ -70,15 +104,14 @@ describe('openComputerUsePermissions', () => { ], nextStep: 'Grant Screen Recording to Orca Computer Use, then retry get-app-state.' }) - expect(spawn).toHaveBeenCalledTimes(1) expect(spawnSync).toHaveBeenCalledWith( '/usr/bin/pkill', - ['-f', 'orca-computer-use-macos --permission'], + ['-f', 'orca-computer-use-macos[[:space:]]+--permission([[:space:]]|$)'], { stdio: 'ignore' } ) expect(spawnSync).toHaveBeenCalledWith( '/usr/bin/pkill', - ['-f', 'orca-computer-use-macos --permissions'], + ['-f', 'orca-computer-use-macos[[:space:]]+--permissions([[:space:]]|$)'], { stdio: 'ignore' } ) expect(spawn).toHaveBeenCalledWith( @@ -88,11 +121,11 @@ describe('openComputerUsePermissions', () => { ) }) - it('launches a targeted permission helper flow', () => { + it('launches a targeted permission helper flow', async () => { resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') mockPermissionStatus('{"accessibility":"not-granted","screenshots":"not-granted"}') - expect(openComputerUsePermissions('accessibility')).toEqual({ + await expect(openComputerUsePermissions('accessibility')).resolves.toEqual({ platform: 'darwin', helperAppPath: '/Applications/Orca Computer Use.app', permissionId: 'accessibility', @@ -111,10 +144,33 @@ describe('openComputerUsePermissions', () => { ) }) - it('returns a no-op result on non-macOS platforms', () => { + it('launches a targeted permission helper even when that permission is already granted', async () => { + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}') + + await expect(openComputerUsePermissions('accessibility')).resolves.toEqual({ + platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + permissionId: 'accessibility', + openedSettings: true, + launchedHelper: true, + permissions: [ + { id: 'accessibility', status: 'granted' }, + { id: 'screenshots', status: 'not-granted' } + ], + nextStep: 'Grant Screen Recording to Orca Computer Use, then retry get-app-state.' + }) + expect(spawn).toHaveBeenCalledWith( + '/usr/bin/open', + ['-n', '/Applications/Orca Computer Use.app', '--args', '--permission', 'accessibility'], + { detached: true, stdio: 'ignore' } + ) + }) + + it('returns a no-op result on non-macOS platforms', async () => { setPlatform('linux') - expect(openComputerUsePermissions()).toEqual({ + await expect(openComputerUsePermissions()).resolves.toEqual({ platform: 'linux', helperAppPath: null, permissionId: undefined, @@ -129,27 +185,56 @@ describe('openComputerUsePermissions', () => { expect(spawn).not.toHaveBeenCalled() }) - it('throws when the helper app is missing on macOS', () => { + it('throws when the helper app is missing on macOS', async () => { resolveHelperAppPathMock.mockReturnValue(null) - expect(() => openComputerUsePermissions()).toThrow('Orca Computer Use.app was not found') + await expect(openComputerUsePermissions()).rejects.toThrow( + 'Orca Computer Use.app was not found' + ) }) - it('throws when the helper executable is missing during setup', () => { + it('throws when the helper executable is missing during setup', async () => { resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') resolveHelperExecutablePathMock.mockReturnValue(null) - expect(() => openComputerUsePermissions('accessibility')).toThrow( + await expect(openComputerUsePermissions('accessibility')).rejects.toThrow( '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos was not found' ) }) - it('reads permission status through the helper app executable', async () => { + it('wraps permission status helper launch failures', async () => { + const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + const child = { + stdout: { on: vi.fn(), setEncoding: vi.fn() }, + stderr: { on: vi.fn(), setEncoding: vi.fn() }, + on: vi.fn((event: string, callback: (error: Error) => void) => { + if (event === 'error') { + queueMicrotask(() => callback(new Error('spawn ENOENT /private/path'))) + } + return child + }), + unref: vi.fn() + } + vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ReturnType) + + await expect(getComputerUsePermissionStatus()).rejects.toMatchObject({ + name: 'RuntimeClientError', + code: 'accessibility_error', + message: 'Could not check permissions: failed to launch helper' + }) + expect(rm).toHaveBeenCalledWith('/tmp/orca-computer-use-permissions-test', { + recursive: true, + force: true + }) + }) + + it('reads permission status through the helper app identity', async () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}') - expect(getComputerUsePermissionStatus()).toEqual({ + await expect(getComputerUsePermissionStatus()).resolves.toEqual({ platform: 'darwin', helperAppPath: '/Applications/Orca Computer Use.app', helperUnavailableReason: null, @@ -158,18 +243,72 @@ describe('openComputerUsePermissions', () => { { id: 'screenshots', status: 'not-granted' } ] }) + expect(spawn).toHaveBeenCalledWith( + '/usr/bin/open', + [ + '-n', + '/Applications/Orca Computer Use.app', + '--args', + '--permission-status-file', + '/tmp/orca-computer-use-permissions-test/status.json' + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ) + expect(spawnSync).not.toHaveBeenCalled() + expect(readFile).toHaveBeenCalledWith( + '/tmp/orca-computer-use-permissions-test/status.json', + 'utf8' + ) + expect(rm).toHaveBeenCalledWith('/tmp/orca-computer-use-permissions-test', { + recursive: true, + force: true + }) + }) + + it('resets stale macOS TCC grants for the helper bundle id', async () => { + resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') + vi.mocked(readFile) + .mockResolvedValueOnce('{"accessibility":"granted","screenshots":"granted"}') + .mockResolvedValueOnce('{"accessibility":"not-granted","screenshots":"not-granted"}') + vi.mocked(execFileSync).mockReturnValueOnce('com.example.orca.computer-use\n') + vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) + + await expect(resetComputerUsePermissions()).resolves.toEqual({ + platform: 'darwin', + helperAppPath: '/Applications/Orca Computer Use.app', + helperUnavailableReason: null, + bundleId: 'com.example.orca.computer-use', + permissions: [ + { id: 'accessibility', status: 'not-granted' }, + { id: 'screenshots', status: 'not-granted' } + ] + }) expect(execFileSync).toHaveBeenCalledWith( - '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos', - ['--permission-status'], + '/usr/libexec/PlistBuddy', + [ + '-c', + 'Print :CFBundleIdentifier', + '/Applications/Orca Computer Use.app/Contents/Info.plist' + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } ) + expect(spawnSync).toHaveBeenCalledWith( + '/usr/bin/tccutil', + ['reset', 'Accessibility', 'com.example.orca.computer-use'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ) + expect(spawnSync).toHaveBeenCalledWith( + '/usr/bin/tccutil', + ['reset', 'ScreenCapture', 'com.example.orca.computer-use'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ) }) it('returns unavailable permission status when the helper app is missing on macOS', async () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') resolveHelperAppPathMock.mockReturnValue(null) - expect(getComputerUsePermissionStatus()).toEqual({ + await expect(getComputerUsePermissionStatus()).resolves.toEqual({ platform: 'darwin', helperAppPath: null, helperUnavailableReason: 'Orca Computer Use.app was not found', @@ -183,8 +322,8 @@ describe('openComputerUsePermissions', () => { }) function mockPermissionStatus(json: string): void { - vi.mocked(spawnSync).mockReturnValue({} as ReturnType) - vi.mocked(execFileSync).mockReturnValue(json) + vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) + vi.mocked(readFile).mockResolvedValue(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 9ec40ef2d..c41fec983 100644 --- a/src/main/computer/macos-computer-use-permissions.ts +++ b/src/main/computer/macos-computer-use-permissions.ts @@ -1,4 +1,8 @@ import { execFileSync, spawn, spawnSync } from 'child_process' +import { mkdtemp, readFile, rm, stat } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { setTimeout as delay } from 'timers/promises' import { RuntimeClientError } from './runtime-client-error' import { resolveMacOSComputerUseAppPath, @@ -6,14 +10,23 @@ import { } from './macos-native-provider-paths' import type { ComputerUsePermissionId, + ComputerUsePermissionResetResult, ComputerUsePermissionSetupResult, ComputerUsePermissionStatus, ComputerUsePermissionStatusResult } from '../../shared/computer-use-permissions-types' +const DEFAULT_COMPUTER_USE_BUNDLE_ID = 'com.stablyai.orca.computer-use' + export function openComputerUsePermissions( permissionId?: ComputerUsePermissionId -): ComputerUsePermissionSetupResult { +): Promise { + return openComputerUsePermissionsAsync(permissionId) +} + +async function openComputerUsePermissionsAsync( + permissionId?: ComputerUsePermissionId +): Promise { if (process.platform !== 'darwin') { return { platform: process.platform, @@ -33,7 +46,7 @@ export function openComputerUsePermissions( if (!helperAppPath) { throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found') } - const status = getComputerUsePermissionStatus() + const status = await getComputerUsePermissionStatus() if (status.helperUnavailableReason) { throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason) } @@ -70,16 +83,64 @@ export function openComputerUsePermissions( } } -function closeExistingPermissionHelpers(): void { - spawnSync('/usr/bin/pkill', ['-f', 'orca-computer-use-macos --permission'], { - stdio: 'ignore' - }) - spawnSync('/usr/bin/pkill', ['-f', 'orca-computer-use-macos --permissions'], { - stdio: 'ignore' - }) +export function resetComputerUsePermissions(): Promise { + return resetComputerUsePermissionsAsync() } -export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusResult { +async function resetComputerUsePermissionsAsync(): Promise { + if (process.platform !== 'darwin') { + return { + platform: process.platform, + helperAppPath: null, + helperUnavailableReason: null, + bundleId: null, + permissions: [ + { id: 'accessibility', status: 'unsupported' }, + { id: 'screenshots', status: 'unsupported' } + ] + } + } + + const helperAppPath = resolveMacOSComputerUseAppPath() + if (!helperAppPath) { + throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found') + } + + const status = await getComputerUsePermissionStatus() + if (status.helperUnavailableReason) { + throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason) + } + + const bundleId = readComputerUseBundleId(helperAppPath) + closeExistingPermissionHelpers() + resetTccPermission('Accessibility', bundleId) + resetTccPermission('ScreenCapture', bundleId) + + return { + ...(await getComputerUsePermissionStatus()), + bundleId + } +} + +function closeExistingPermissionHelpers(): void { + // Why: status probes use --permission-status-file and must not be killed + // while setup helpers are being replaced. + const setupHelperPatterns = [ + 'orca-computer-use-macos[[:space:]]+--permission([[:space:]]|$)', + 'orca-computer-use-macos[[:space:]]+--permissions([[:space:]]|$)' + ] + for (const pattern of setupHelperPatterns) { + spawnSync('/usr/bin/pkill', ['-f', pattern], { + stdio: 'ignore' + }) + } +} + +export function getComputerUsePermissionStatus(): Promise { + return getComputerUsePermissionStatusAsync() +} + +async function getComputerUsePermissionStatusAsync(): Promise { if (process.platform !== 'darwin') { return { platform: process.platform, @@ -105,7 +166,7 @@ export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusRes ) } - const raw = readPermissionStatusFromHelperExecutable(executablePath) + const raw = await readPermissionStatusFromHelperApp(helperAppPath) return { platform: process.platform, @@ -133,16 +194,111 @@ function createUnavailablePermissionStatus( } } -function readPermissionStatusFromHelperExecutable( - executablePath: string -): Partial> { - // 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'] +async function readPermissionStatusFromHelperApp( + helperAppPath: string +): Promise>> { + const tempDir = await mkdtemp(join(tmpdir(), 'orca-computer-use-permissions-')) + const statusPath = join(tempDir, 'status.json') + try { + // Why: TCC status must be checked through the helper app identity. Directly + // execing the binary can inherit the parent app's already-granted context. + await launchPermissionStatusHelper(helperAppPath, statusPath) + + for (let attempt = 0; attempt < 50; attempt++) { + if (await fileExists(statusPath)) { + const output = await readFile(statusPath, 'utf8') + return JSON.parse(output) as Partial< + Record + > + } + await delay(100) + } + throw new RuntimeClientError('accessibility_error', 'Timed out checking permissions') + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +} + +function launchPermissionStatusHelper(helperAppPath: string, statusPath: string): Promise { + return new Promise((resolve, reject) => { + const launch = spawn( + '/usr/bin/open', + ['-n', helperAppPath, '--args', '--permission-status-file', statusPath], + { + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let stdout = '' + let stderr = '' + + launch.stdout?.setEncoding('utf8') + launch.stderr?.setEncoding('utf8') + launch.stdout?.on('data', (chunk) => { + stdout += chunk + }) + launch.stderr?.on('data', (chunk) => { + stderr += chunk + }) + launch.on('error', () => { + reject( + new RuntimeClientError( + 'accessibility_error', + 'Could not check permissions: failed to launch helper' + ) + ) + }) + launch.on('close', (status) => { + if (status === 0) { + resolve() + return + } + const detail = stderr.trim() || stdout.trim() || `exit ${status ?? 'unknown'}` + reject( + new RuntimeClientError('accessibility_error', `Could not check permissions: ${detail}`) + ) + }) }) - return JSON.parse(output) as Partial> +} + +async function fileExists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +function readComputerUseBundleId(helperAppPath: string): string { + const infoPlistPath = join(helperAppPath, 'Contents', 'Info.plist') + try { + const bundleId = execFileSync( + '/usr/libexec/PlistBuddy', + ['-c', 'Print :CFBundleIdentifier', infoPlistPath], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + } + ).trim() + return bundleId || DEFAULT_COMPUTER_USE_BUNDLE_ID + } catch { + return DEFAULT_COMPUTER_USE_BUNDLE_ID + } +} + +function resetTccPermission(service: string, bundleId: string): void { + // Why: macOS keeps TCC rows after uninstall; users need an explicit way to + // clear stale grants or denials for the helper's stable bundle identity. + const result = spawnSync('/usr/bin/tccutil', ['reset', service, bundleId], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + if (result.status === 0) { + return + } + const detail = + result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? 'unknown'}` + throw new RuntimeClientError('accessibility_error', `Could not reset ${service}: ${detail}`) } function nextPermissionStep( diff --git a/src/main/ipc/computer-use-permissions.ts b/src/main/ipc/computer-use-permissions.ts index 8665fa9a0..af6d19d68 100644 --- a/src/main/ipc/computer-use-permissions.ts +++ b/src/main/ipc/computer-use-permissions.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import type { ComputerUsePermissionId, + ComputerUsePermissionResetResult, ComputerUsePermissionSetupResult, ComputerUsePermissionStatusResult } from '../../shared/computer-use-permissions-types' @@ -25,4 +26,12 @@ export function registerComputerUsePermissionHandlers(): void { return getComputerUsePermissionStatus() } ) + ipcMain.handle( + 'computerUsePermissions:reset', + async (): Promise => { + const { resetComputerUsePermissions } = + await import('../computer/macos-computer-use-permissions') + return resetComputerUsePermissions() + } + ) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f042239ed..e85065213 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -205,6 +205,7 @@ import type { } from '../shared/developer-permissions-types' import type { ComputerUsePermissionId, + ComputerUsePermissionResetResult, ComputerUsePermissionSetupResult, ComputerUsePermissionStatusResult } from '../shared/computer-use-permissions-types' @@ -1341,6 +1342,7 @@ export type PreloadApi = { openSetup: (args?: { id?: ComputerUsePermissionId }) => Promise + reset: () => Promise } shell: { openPath: (path: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 352429a8b..cae4f791f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1468,7 +1468,8 @@ const api = { computerUsePermissions: { getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), openSetup: (args?: { id?: string }): Promise => - ipcRenderer.invoke('computerUsePermissions:openSetup', args) + ipcRenderer.invoke('computerUsePermissions:openSetup', args), + reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') }, shell: { diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 3eaa52388..be3de086e 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type ReactNode } from 'react' -import { Terminal } from 'lucide-react' +import { RefreshCw, Terminal } from 'lucide-react' import { IntegrationStatusPill } from '../integration-status-pill' import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' import { Button } from '../ui/button' @@ -128,9 +128,11 @@ export function AgentSkillSetupPanel({ type="button" variant="ghost" size="sm" + className="gap-1.5" onClick={() => void onRecheck()} disabled={loading} > + Re-check ) : null} diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx index 6f20eb834..ef3c61ae9 100644 --- a/src/renderer/src/components/settings/ComputerUsePane.tsx +++ b/src/renderer/src/components/settings/ComputerUsePane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Accessibility, Camera, @@ -26,6 +26,7 @@ import { useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' import { Button } from '../ui/button' +import { Badge } from '../ui/badge' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' export { COMPUTER_USE_PANE_SEARCH_ENTRIES } from './computer-use-search' @@ -75,6 +76,10 @@ export function ComputerUsePane(): React.JSX.Element { const [states, setStates] = useState([]) const [loading, setLoading] = useState(true) const [pendingId, setPendingId] = useState(null) + const [resetting, setResetting] = useState(false) + // Why: reset changes OS permission state, so older status probes must not overwrite it. + const resettingRef = useRef(false) + const permissionOperationSequence = useRef(0) const [helperUnavailableReason, setHelperUnavailableReason] = useState(null) const { installed: computerUseSkillDetected, @@ -89,20 +94,57 @@ export function ComputerUsePane(): React.JSX.Element { () => new Map(states.map((state) => [state.id, state.status] as const)), [states] ) + const grantedCount = PERMISSIONS.filter( + (permission) => stateById.get(permission.id) === 'granted' + ).length + const allGranted = grantedCount === PERMISSIONS.length + const checking = loading && states.length === 0 + const setupUnavailable = helperUnavailableReason !== null + const resetAccessDisabled = + resetting || loading || states.length === 0 || pendingId !== null || setupUnavailable + const summaryTitle = checking + ? 'Checking Computer Use access.' + : setupUnavailable + ? 'Computer Use is unavailable.' + : allGranted + ? 'Computer Use is ready.' + : 'Finish setup to use local apps.' + const summaryDescription = checking + ? 'Orca is checking macOS privacy permissions for the Computer Use helper.' + : setupUnavailable + ? `Computer Use permissions are unavailable because ${helperUnavailableReason}.` + : allGranted + ? 'Agents can inspect and operate app windows when you ask.' + : `${PERMISSIONS.length - grantedCount} permission${ + PERMISSIONS.length - grantedCount === 1 ? '' : 's' + } required before agents can operate app windows.` const refresh = useCallback(async (): Promise => { + if (resettingRef.current) { + return + } + + const operationId = ++permissionOperationSequence.current setLoading(true) try { const result = await window.api.computerUsePermissions.getStatus() + if (operationId !== permissionOperationSequence.current) { + return + } setPlatform(result.platform) setStates(result.permissions) setHelperUnavailableReason(result.helperUnavailableReason) } catch (error) { + if (operationId !== permissionOperationSequence.current) { + return + } toast.error( error instanceof Error ? error.message : 'Could not load Computer Use permissions' ) } finally { - setLoading(false) + if (operationId === permissionOperationSequence.current) { + setLoading(false) + } } }, []) @@ -127,7 +169,11 @@ export function ComputerUsePane(): React.JSX.Element { if (result.launchedHelper) { toast.message('Opened macOS Privacy & Security') } else { - toast.message('Computer Use permissions are only required on macOS') + toast.message( + result.platform === 'darwin' + ? 'Computer Use setup is already complete' + : 'Computer Use permissions are only required on macOS' + ) } } catch (error) { toast.error( @@ -138,75 +184,129 @@ export function ComputerUsePane(): React.JSX.Element { } } + const resetAccess = async (): Promise => { + if (resettingRef.current) { + return + } + + resettingRef.current = true + const operationId = ++permissionOperationSequence.current + setResetting(true) + try { + const result = await window.api.computerUsePermissions.reset() + if (operationId !== permissionOperationSequence.current) { + return + } + setPlatform(result.platform) + setStates(result.permissions) + setHelperUnavailableReason(result.helperUnavailableReason) + toast.message('Reset Computer Use access') + } catch (error) { + if (operationId !== permissionOperationSequence.current) { + return + } + toast.error( + error instanceof Error ? error.message : 'Could not reset Computer Use permissions' + ) + } finally { + if (operationId === permissionOperationSequence.current) { + resettingRef.current = false + setResetting(false) + setLoading(false) + } + } + } + const isMac = platform === null || platform === 'darwin' return (
{isMac ? ( <> -
+
- Allow Orca to use local apps when you ask. + {summaryTitle} + {allGranted ? ( + + Ready + + ) : null}
-

- Computer Use needs macOS privacy permissions before agents can inspect and operate - app windows. -

- {helperUnavailableReason ? ( -

- Computer Use permissions are unavailable because {helperUnavailableReason}. -

- ) : null} +

{summaryDescription}

-
-
- {PERMISSIONS.map((permission) => { - const status = stateById.get(permission.id) - const pending = pendingId === permission.id +
+
+ {PERMISSIONS.map((permission) => { + const status = stateById.get(permission.id) + const pending = pendingId === permission.id - return ( -
-
-
{permission.icon}
-
-
- {permission.label} - - {statusLabel(status)} - + return ( +
+
+
{permission.icon}
+
+
+ {permission.label} + + {statusLabel(status)} + +
+

{permission.description}

-

{permission.description}

+
+
+
- -
- ) - })} + ) + })} +
+
) : null} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 01b717c77..9f457cd81 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1700,6 +1700,14 @@ function createComputerUsePermissionsApi(): NonNullable< openedSettings: false, launchedHelper: false, nextStep: 'Computer-use permissions are managed on the Orca server.' + }), + reset: () => + Promise.resolve({ + platform: getBrowserPlatform(), + helperAppPath: null, + helperUnavailableReason: 'web_client', + bundleId: null, + permissions: [] }) } } diff --git a/src/shared/computer-use-permissions-types.ts b/src/shared/computer-use-permissions-types.ts index d1f4c488f..92fbcb3dc 100644 --- a/src/shared/computer-use-permissions-types.ts +++ b/src/shared/computer-use-permissions-types.ts @@ -23,3 +23,7 @@ export type ComputerUsePermissionSetupResult = { permissions?: ComputerUsePermissionState[] nextStep?: string | null } + +export type ComputerUsePermissionResetResult = ComputerUsePermissionStatusResult & { + bundleId: string | null +}