diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx
index b38dd120..3f93f3b5 100644
--- a/desktop/src/__tests__/pages.test.tsx
+++ b/desktop/src/__tests__/pages.test.tsx
@@ -49,6 +49,7 @@ describe('Content-only pages render without errors', () => {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
@@ -83,6 +84,7 @@ describe('Content-only pages render without errors', () => {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
diff --git a/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx b/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx
new file mode 100644
index 00000000..c2bfce00
--- /dev/null
+++ b/desktop/src/components/chat/ComputerUsePermissionModal.test.tsx
@@ -0,0 +1,174 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, fireEvent, render, screen } from '@testing-library/react'
+
+const { sendMock, openSettingsMock } = vi.hoisted(() => ({
+ sendMock: vi.fn(),
+ openSettingsMock: vi.fn(async () => ({ ok: true })),
+}))
+
+vi.mock('../../api/websocket', () => ({
+ wsManager: {
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ onMessage: vi.fn(() => () => {}),
+ clearHandlers: vi.fn(),
+ send: sendMock,
+ },
+}))
+
+vi.mock('../../api/sessions', () => ({
+ sessionsApi: {
+ getMessages: vi.fn(async () => ({ messages: [] })),
+ getSlashCommands: vi.fn(async () => ({ commands: [] })),
+ },
+}))
+
+vi.mock('../../stores/teamStore', () => ({
+ useTeamStore: {
+ getState: () => ({
+ getMemberBySessionId: vi.fn(() => null),
+ sendMessageToMember: vi.fn(async () => {}),
+ handleTeamCreated: vi.fn(),
+ handleTeamUpdate: vi.fn(),
+ handleTeamDeleted: vi.fn(),
+ }),
+ },
+}))
+
+vi.mock('../../stores/tabStore', () => ({
+ useTabStore: {
+ getState: () => ({
+ updateTabStatus: vi.fn(),
+ updateTabTitle: vi.fn(),
+ }),
+ },
+}))
+
+vi.mock('../../stores/sessionStore', () => ({
+ useSessionStore: {
+ getState: () => ({
+ updateSessionTitle: vi.fn(),
+ }),
+ },
+}))
+
+vi.mock('../../stores/cliTaskStore', () => ({
+ useCLITaskStore: {
+ getState: () => ({
+ fetchSessionTasks: vi.fn(),
+ tasks: [],
+ clearTasks: vi.fn(),
+ setTasksFromTodos: vi.fn(),
+ markCompletedAndDismissed: vi.fn(),
+ refreshTasks: vi.fn(),
+ }),
+ },
+}))
+
+vi.mock('../../api/computerUse', () => ({
+ computerUseApi: {
+ openSettings: openSettingsMock,
+ },
+}))
+
+import { useChatStore } from '../../stores/chatStore'
+import { ComputerUsePermissionModal } from './ComputerUsePermissionModal'
+
+describe('ComputerUsePermissionModal', () => {
+ beforeEach(() => {
+ sendMock.mockReset()
+ openSettingsMock.mockReset()
+ useChatStore.setState({ sessions: {} })
+ })
+
+ it('returns a full approval payload for resolved apps and requested flags', () => {
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Allow for session' }))
+
+ expect(sendMock).toHaveBeenCalledTimes(1)
+ expect(sendMock).toHaveBeenCalledWith('session-1', {
+ type: 'computer_use_permission_response',
+ requestId: 'cu-1',
+ response: {
+ granted: [
+ expect.objectContaining({
+ bundleId: 'com.apple.finder',
+ displayName: 'Finder',
+ tier: 'full',
+ }),
+ ],
+ denied: [
+ {
+ bundleId: 'Missing App',
+ reason: 'not_installed',
+ },
+ ],
+ flags: {
+ clipboardRead: true,
+ clipboardWrite: false,
+ systemKeyCombos: true,
+ },
+ userConsented: true,
+ },
+ })
+ })
+
+ it('opens System Settings from the macOS permission panel', async () => {
+ render(
+ ,
+ )
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Open Accessibility' }))
+ })
+
+ expect(openSettingsMock).toHaveBeenCalledWith('Privacy_Accessibility')
+ })
+})
diff --git a/desktop/src/components/chat/ComputerUsePermissionModal.tsx b/desktop/src/components/chat/ComputerUsePermissionModal.tsx
new file mode 100644
index 00000000..b6a9c9e9
--- /dev/null
+++ b/desktop/src/components/chat/ComputerUsePermissionModal.tsx
@@ -0,0 +1,311 @@
+import { useMemo, useState } from 'react'
+import { useTranslation } from '../../i18n'
+import { computerUseApi } from '../../api/computerUse'
+import { useChatStore } from '../../stores/chatStore'
+import type {
+ ComputerUsePermissionRequest,
+ ComputerUsePermissionResponse,
+} from '../../types/chat'
+import { Button } from '../shared/Button'
+import { Modal } from '../shared/Modal'
+
+type Props = {
+ sessionId: string
+ request: ComputerUsePermissionRequest | null
+}
+
+const DEFAULT_GRANT_FLAGS = {
+ clipboardRead: false,
+ clipboardWrite: false,
+ systemKeyCombos: false,
+} as const
+
+function denyAllResponse(): ComputerUsePermissionResponse {
+ return {
+ granted: [],
+ denied: [],
+ flags: { ...DEFAULT_GRANT_FLAGS },
+ userConsented: false,
+ }
+}
+
+function buildAllowResponse(
+ request: ComputerUsePermissionRequest,
+): ComputerUsePermissionResponse {
+ const now = Date.now()
+ const granted = request.apps.flatMap((app) => {
+ if (!app.resolved || app.alreadyGranted) return []
+ return [{
+ bundleId: app.resolved.bundleId,
+ displayName: app.resolved.displayName,
+ grantedAt: now,
+ tier: app.proposedTier,
+ }]
+ })
+
+ const denied = request.apps.flatMap((app) => {
+ if (app.resolved) return []
+ return [{
+ bundleId: app.requestedName,
+ reason: 'not_installed' as const,
+ }]
+ })
+
+ const flags = {
+ ...DEFAULT_GRANT_FLAGS,
+ ...Object.fromEntries(
+ Object.entries(request.requestedFlags).filter(([, value]) => value === true),
+ ),
+ }
+
+ return {
+ granted,
+ denied,
+ flags,
+ userConsented: true,
+ }
+}
+
+export function ComputerUsePermissionModal({ sessionId, request }: Props) {
+ const t = useTranslation()
+ const respondToComputerUsePermission = useChatStore(
+ (s) => s.respondToComputerUsePermission,
+ )
+ const [openingPane, setOpeningPane] = useState<
+ 'Privacy_Accessibility' | 'Privacy_ScreenCapture' | null
+ >(null)
+
+ const requestedFlags = useMemo(
+ () =>
+ request
+ ? Object.entries(request.requestedFlags)
+ .filter(([, enabled]) => enabled)
+ .map(([flag]) => flag)
+ : [],
+ [request],
+ )
+
+ if (!request) return null
+
+ const handleDeny = () => {
+ respondToComputerUsePermission(
+ sessionId,
+ request.requestId,
+ denyAllResponse(),
+ )
+ }
+
+ const handleAllow = () => {
+ respondToComputerUsePermission(
+ sessionId,
+ request.requestId,
+ buildAllowResponse(request),
+ )
+ }
+
+ const openSettings = async (
+ pane: 'Privacy_Accessibility' | 'Privacy_ScreenCapture',
+ ) => {
+ setOpeningPane(pane)
+ try {
+ await computerUseApi.openSettings(pane)
+ } finally {
+ setOpeningPane(null)
+ }
+ }
+
+ const tccState = request.tccState
+
+ return (
+
+ {t('computerUseApproval.deny')}
+
+ ) : (
+ <>
+
+
+ >
+ )
+ }
+ >
+ {tccState ? (
+
+
+ {t('computerUseApproval.tccHint')}
+
+
+
+
openSettings('Privacy_Accessibility')}
+ />
+ openSettings('Privacy_ScreenCapture')}
+ />
+
+
+
+ {t('computerUseApproval.tryAgainHint')}
+
+
+
+
+
+
+ ) : (
+
+ {request.reason ? (
+
+
+ {t('computerUseApproval.reason')}
+
+
+ {request.reason}
+
+
+ ) : null}
+
+
+ {request.apps.map((app) => {
+ const resolved = app.resolved
+ return (
+
+
+
+
+ {resolved?.displayName ?? app.requestedName}
+
+
+ {resolved?.bundleId ?? t('computerUseApproval.notInstalled')}
+
+
+
+ {app.proposedTier}
+
+
+
+ {!resolved ? (
+
+ {t('computerUseApproval.notInstalled')}
+
+ ) : null}
+
+ {app.alreadyGranted ? (
+
+ {t('computerUseApproval.alreadyGranted')}
+
+ ) : null}
+
+ {app.isSentinel ? (
+
+ {t('computerUseApproval.sensitiveApp')}
+
+ ) : null}
+
+ )
+ })}
+
+
+ {requestedFlags.length > 0 ? (
+
+
+ {t('computerUseApproval.alsoRequested')}
+
+
+ {requestedFlags.map((flag) => (
+
+ {flag}
+
+ ))}
+
+
+ ) : null}
+
+ {request.willHide && request.willHide.length > 0 ? (
+
+ {request.autoUnhideEnabled
+ ? t('computerUseApproval.hideWhileWorkingRestore', {
+ count: request.willHide.length,
+ })
+ : t('computerUseApproval.hideWhileWorking', {
+ count: request.willHide.length,
+ })}
+
+ ) : null}
+
+ )}
+
+ )
+}
+
+function PermissionRow({
+ label,
+ granted,
+ actionLabel,
+ actionLoading,
+ onAction,
+}: {
+ label: string
+ granted: boolean
+ actionLabel: string
+ actionLoading: boolean
+ onAction: () => void
+}) {
+ const t = useTranslation()
+
+ return (
+
+
+
+ {label}
+
+
+ {granted
+ ? t('computerUseApproval.granted')
+ : t('computerUseApproval.notGranted')}
+
+
+
+ {!granted ? (
+
+ ) : null}
+
+ )
+}
diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index ca1e83d2..9ad7523c 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -19,6 +19,7 @@ function makeSessionState(overrides: Partial = {}): PerSessionS
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
diff --git a/desktop/src/components/chat/ToolCallBlock.tsx b/desktop/src/components/chat/ToolCallBlock.tsx
index f8e53ee2..fb07abea 100644
--- a/desktop/src/components/chat/ToolCallBlock.tsx
+++ b/desktop/src/components/chat/ToolCallBlock.tsx
@@ -41,7 +41,8 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
const details = useMemo(() => renderDetails(toolName, obj, t), [obj, toolName, t])
- const expandable = toolName === 'Edit' || toolName === 'Write'
+ const hasResultDetails = Boolean(result && extractTextContent(result.content))
+ const expandable = toolName === 'Edit' || toolName === 'Write' || hasResultDetails
return (
)}
- {result?.isError && (
+ {result?.isError && (
error
)}
{expandable && (
diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx
index 2e999e26..45f00d68 100644
--- a/desktop/src/components/chat/chatBlocks.test.tsx
+++ b/desktop/src/components/chat/chatBlocks.test.tsx
@@ -40,7 +40,7 @@ describe('chat blocks', () => {
fireEvent.click(screen.getByRole('button'))
- expect(container.textContent).not.toContain('Tool Input')
+ expect(container.textContent).toContain('Tool Input')
expect(container.textContent).not.toContain('const answer = 42')
})
@@ -62,6 +62,27 @@ describe('chat blocks', () => {
expect(container.textContent).not.toContain('file-a')
})
+ it('expands tool errors so full Computer Use gate messages are readable', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(container.textContent).toContain('mcp__computer-use__left_click')
+ expect(container.textContent).not.toContain('Take a new screenshot')
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(container.textContent).toContain('Take a new screenshot')
+ expect(container.textContent).toContain('allowed applications')
+ })
+
it('shows a diff preview for edit permission requests', () => {
useChatStore.setState({
sessions: {
@@ -83,6 +104,7 @@ describe('chat blocks', () => {
new_string: 'const count = 2',
},
},
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index 316432b6..f7e5b778 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -331,6 +331,28 @@ export const en = {
'permission.showFullInput': 'Show full input',
'permission.replacingContent': 'Replacing content in file',
+ // ─── Computer Use Approval ──────────────────────────────────────
+ 'computerUseApproval.titleApps': 'Computer Use wants to control these apps',
+ 'computerUseApproval.titleTcc': 'Computer Use needs macOS permissions',
+ 'computerUseApproval.reason': 'Why Claude is asking',
+ 'computerUseApproval.allow': 'Allow for session',
+ 'computerUseApproval.deny': 'Deny',
+ 'computerUseApproval.alreadyGranted': 'Already granted for this session',
+ 'computerUseApproval.notInstalled': 'App not installed',
+ 'computerUseApproval.sensitiveApp': 'This app is treated as sensitive and deserves extra review.',
+ 'computerUseApproval.alsoRequested': 'Also requested',
+ 'computerUseApproval.hideWhileWorking': '{count} other apps will be hidden while Claude works.',
+ 'computerUseApproval.hideWhileWorkingRestore': '{count} other apps will be hidden while Claude works, then restored when Claude is done.',
+ 'computerUseApproval.accessibility': 'Accessibility',
+ 'computerUseApproval.screenRecording': 'Screen Recording',
+ 'computerUseApproval.granted': 'Granted',
+ 'computerUseApproval.notGranted': 'Not granted',
+ 'computerUseApproval.openAccessibility': 'Open Accessibility',
+ 'computerUseApproval.openScreenRecording': 'Open Screen Recording',
+ 'computerUseApproval.tryAgain': 'Try again',
+ 'computerUseApproval.tccHint': 'Grant the missing permissions in System Settings, then come back and choose "Try again".',
+ 'computerUseApproval.tryAgainHint': 'Try again returns control to Claude so it can call request_access once more after macOS permission changes take effect.',
+
// ─── Ask User Question ──────────────────────────────────────
'question.needsInput': 'Claude needs your input',
'question.answered': 'Answered',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 56401ef8..455e915e 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -333,6 +333,28 @@ export const zh: Record = {
'permission.showFullInput': '显示完整输入',
'permission.replacingContent': '替换文件内容',
+ // ─── Computer Use Approval ──────────────────────────────────────
+ 'computerUseApproval.titleApps': 'Computer Use 想控制这些应用',
+ 'computerUseApproval.titleTcc': 'Computer Use 需要 macOS 权限',
+ 'computerUseApproval.reason': '请求原因',
+ 'computerUseApproval.allow': '本次会话允许',
+ 'computerUseApproval.deny': '拒绝',
+ 'computerUseApproval.alreadyGranted': '本次会话已授权',
+ 'computerUseApproval.notInstalled': '应用未安装',
+ 'computerUseApproval.sensitiveApp': '该应用属于高敏感类别,请额外确认后再授权。',
+ 'computerUseApproval.alsoRequested': '同时请求了',
+ 'computerUseApproval.hideWhileWorking': 'Claude 工作时会隐藏另外 {count} 个应用。',
+ 'computerUseApproval.hideWhileWorkingRestore': 'Claude 工作时会隐藏另外 {count} 个应用,结束后会自动恢复。',
+ 'computerUseApproval.accessibility': '辅助功能',
+ 'computerUseApproval.screenRecording': '屏幕录制',
+ 'computerUseApproval.granted': '已授权',
+ 'computerUseApproval.notGranted': '未授权',
+ 'computerUseApproval.openAccessibility': '打开辅助功能设置',
+ 'computerUseApproval.openScreenRecording': '打开屏幕录制设置',
+ 'computerUseApproval.tryAgain': '稍后重试',
+ 'computerUseApproval.tccHint': '先在系统设置里授予缺失权限,返回后再点“稍后重试”。',
+ 'computerUseApproval.tryAgainHint': '“稍后重试”会把控制权交还给 Claude,让它在 macOS 权限生效后重新调用 request_access。',
+
// ─── Ask User Question ──────────────────────────────────────
'question.needsInput': 'Claude 需要你的输入',
'question.answered': '已回答',
diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx
index a4100d13..52f17ff0 100644
--- a/desktop/src/pages/ActiveSession.test.tsx
+++ b/desktop/src/pages/ActiveSession.test.tsx
@@ -79,6 +79,7 @@ describe('ActiveSession task polling', () => {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
@@ -157,6 +158,7 @@ describe('ActiveSession task polling', () => {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx
index 7e8515f4..6919b998 100644
--- a/desktop/src/pages/ActiveSession.tsx
+++ b/desktop/src/pages/ActiveSession.tsx
@@ -7,6 +7,7 @@ import { useTeamStore } from '../stores/teamStore'
import { useTranslation } from '../i18n'
import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
+import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
@@ -17,6 +18,7 @@ export function ActiveSession() {
const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
+ const pendingComputerUsePermission = sessionState?.pendingComputerUsePermission ?? null
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId)
const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed'))
@@ -205,6 +207,13 @@ export function ActiveSession() {
+
+ {!isMemberSession && activeTabId ? (
+
+ ) : null}
)
}
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index 811e3d09..9ac30215 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -166,6 +166,7 @@ describe('chatStore history mapping', () => {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
@@ -222,6 +223,7 @@ describe('chatStore history mapping', () => {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
@@ -252,6 +254,7 @@ describe('chatStore history mapping', () => {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
@@ -300,6 +303,7 @@ describe('chatStore history mapping', () => {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
@@ -325,6 +329,132 @@ describe('chatStore history mapping', () => {
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingText).toBe('')
})
+ it('tracks Computer Use approval requests separately from generic tool permissions', () => {
+ useChatStore.setState({
+ sessions: {
+ [TEST_SESSION_ID]: {
+ messages: [],
+ chatState: 'idle',
+ connectionState: 'connected',
+ streamingText: '',
+ streamingToolInput: '',
+ activeToolUseId: null,
+ activeToolName: null,
+ activeThinkingId: null,
+ pendingPermission: null,
+ pendingComputerUsePermission: null,
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
+ elapsedSeconds: 0,
+ statusVerb: '',
+ slashCommands: [],
+ agentTaskNotifications: {},
+ elapsedTimer: null,
+ },
+ },
+ })
+
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'computer_use_permission_request',
+ requestId: 'cu-1',
+ request: {
+ requestId: 'cu-1',
+ reason: 'Open Finder and inspect a file',
+ apps: [
+ {
+ requestedName: 'Finder',
+ resolved: {
+ bundleId: 'com.apple.finder',
+ displayName: 'Finder',
+ },
+ isSentinel: false,
+ alreadyGranted: false,
+ proposedTier: 'full',
+ },
+ ],
+ requestedFlags: { clipboardRead: true },
+ screenshotFiltering: 'native',
+ },
+ })
+
+ expect(
+ useChatStore.getState().sessions[TEST_SESSION_ID]?.pendingComputerUsePermission,
+ ).toMatchObject({
+ requestId: 'cu-1',
+ request: {
+ reason: 'Open Finder and inspect a file',
+ },
+ })
+ expect(
+ useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState,
+ ).toBe('permission_pending')
+ })
+
+ it('sends Computer Use approval payloads back over websocket', () => {
+ useChatStore.setState({
+ sessions: {
+ [TEST_SESSION_ID]: {
+ messages: [],
+ chatState: 'permission_pending',
+ connectionState: 'connected',
+ streamingText: '',
+ streamingToolInput: '',
+ activeToolUseId: null,
+ activeToolName: null,
+ activeThinkingId: null,
+ pendingPermission: null,
+ pendingComputerUsePermission: {
+ requestId: 'cu-1',
+ request: {
+ requestId: 'cu-1',
+ reason: 'Open Finder',
+ apps: [],
+ requestedFlags: {},
+ screenshotFiltering: 'native',
+ },
+ },
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
+ elapsedSeconds: 0,
+ statusVerb: '',
+ slashCommands: [],
+ agentTaskNotifications: {},
+ elapsedTimer: null,
+ },
+ },
+ })
+
+ useChatStore.getState().respondToComputerUsePermission(TEST_SESSION_ID, 'cu-1', {
+ granted: [],
+ denied: [],
+ flags: {
+ clipboardRead: true,
+ clipboardWrite: false,
+ systemKeyCombos: false,
+ },
+ userConsented: true,
+ })
+
+ expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
+ type: 'computer_use_permission_response',
+ requestId: 'cu-1',
+ response: {
+ granted: [],
+ denied: [],
+ flags: {
+ clipboardRead: true,
+ clipboardWrite: false,
+ systemKeyCombos: false,
+ },
+ userConsented: true,
+ },
+ })
+ expect(
+ useChatStore.getState().sessions[TEST_SESSION_ID]?.pendingComputerUsePermission,
+ ).toBeNull()
+ expect(
+ useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState,
+ ).toBe('tool_executing')
+ })
+
it('routes member-session messages through team mailbox delivery instead of websocket', async () => {
const memberSessionId = 'team-member:security-reviewer@test-team'
getMemberBySessionIdMock.mockReturnValue({
@@ -345,6 +475,7 @@ describe('chatStore history mapping', () => {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index c64f7f1a..e8420753 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -13,6 +13,8 @@ import type {
AgentTaskNotification,
AttachmentRef,
ChatState,
+ ComputerUsePermissionRequest,
+ ComputerUsePermissionResponse,
UIAttachment,
UIMessage,
ServerMessage,
@@ -36,6 +38,10 @@ export type PerSessionState = {
input: unknown
description?: string
} | null
+ pendingComputerUsePermission: {
+ requestId: string
+ request: ComputerUsePermissionRequest
+ } | null
tokenUsage: TokenUsage
elapsedSeconds: number
statusVerb: string
@@ -54,6 +60,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
@@ -74,6 +81,11 @@ type ChatStore = {
disconnectSession: (sessionId: string) => void
sendMessage: (sessionId: string, content: string, attachments?: AttachmentRef[]) => void
respondToPermission: (sessionId: string, requestId: string, allowed: boolean, rule?: string) => void
+ respondToComputerUsePermission: (
+ sessionId: string,
+ requestId: string,
+ response: ComputerUsePermissionResponse,
+ ) => void
setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void
stopGeneration: (sessionId: string) => void
loadHistory: (sessionId: string) => Promise
@@ -272,6 +284,20 @@ export const useChatStore = create((set, get) => ({
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })) }))
},
+ respondToComputerUsePermission: (sessionId, requestId, response) => {
+ wsManager.send(sessionId, {
+ type: 'computer_use_permission_response',
+ requestId,
+ response,
+ })
+ set((s) => ({
+ sessions: updateSessionIn(s.sessions, sessionId, () => ({
+ pendingComputerUsePermission: null,
+ chatState: response.userConsented === false ? 'idle' : 'tool_executing',
+ })),
+ }))
+ },
+
setSessionPermissionMode: (sessionId, mode) => {
if (!get().sessions[sessionId]) return
wsManager.send(sessionId, { type: 'set_permission_mode', mode })
@@ -289,7 +315,18 @@ export const useChatStore = create((set, get) => ({
const session = s.sessions[sessionId]
if (!session) return s
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
- return { sessions: { ...s.sessions, [sessionId]: { ...session, chatState: 'idle', elapsedTimer: null } } }
+ return {
+ sessions: {
+ ...s.sessions,
+ [sessionId]: {
+ ...session,
+ chatState: 'idle',
+ pendingPermission: null,
+ pendingComputerUsePermission: null,
+ elapsedTimer: null,
+ },
+ },
+ }
})
},
@@ -456,6 +493,7 @@ export const useChatStore = create((set, get) => ({
case 'permission_request':
update((s) => ({
pendingPermission: { requestId: msg.requestId, toolName: msg.toolName, input: msg.input, description: msg.description },
+ pendingComputerUsePermission: null,
chatState: 'permission_pending',
activeThinkingId: null,
messages: [...s.messages, {
@@ -465,6 +503,18 @@ export const useChatStore = create((set, get) => ({
}))
break
+ case 'computer_use_permission_request':
+ update(() => ({
+ pendingComputerUsePermission: {
+ requestId: msg.requestId,
+ request: msg.request,
+ },
+ pendingPermission: null,
+ chatState: 'permission_pending',
+ activeThinkingId: null,
+ }))
+ break
+
case 'message_complete': {
const session = get().sessions[sessionId]
if (!session) break
@@ -476,7 +526,14 @@ export const useChatStore = create((set, get) => ({
}))
}
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
- update(() => ({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null, elapsedTimer: null }))
+ update(() => ({
+ tokenUsage: msg.usage,
+ chatState: 'idle',
+ activeThinkingId: null,
+ pendingPermission: null,
+ pendingComputerUsePermission: null,
+ elapsedTimer: null,
+ }))
break
}
@@ -488,7 +545,14 @@ export const useChatStore = create((set, get) => ({
newMessages.push({ id: nextId(), type: 'assistant_text' as const, content: pendingText, timestamp: Date.now() })
}
newMessages.push({ id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() })
- return { messages: newMessages, chatState: 'idle', activeThinkingId: null, streamingText: '' }
+ return {
+ messages: newMessages,
+ chatState: 'idle',
+ activeThinkingId: null,
+ streamingText: '',
+ pendingPermission: null,
+ pendingComputerUsePermission: null,
+ }
})
useTabStore.getState().updateTabStatus(sessionId, 'error')
{
diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts
index 93e0b574..ce6d7e71 100644
--- a/desktop/src/stores/teamStore.ts
+++ b/desktop/src/stores/teamStore.ts
@@ -27,6 +27,7 @@ function createMemberSessionState() {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
+ pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts
index dddebead..d9666321 100644
--- a/desktop/src/types/chat.ts
+++ b/desktop/src/types/chat.ts
@@ -7,6 +7,11 @@ import type { PermissionMode } from './settings'
export type ClientMessage =
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
| { type: 'permission_response'; requestId: string; allowed: boolean; rule?: string }
+ | {
+ type: 'computer_use_permission_response'
+ requestId: string
+ response: ComputerUsePermissionResponse
+ }
| { type: 'set_permission_mode'; mode: PermissionMode }
| { type: 'stop_generation' }
| { type: 'ping' }
@@ -35,6 +40,11 @@ export type ServerMessage =
| { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown; parentToolUseId?: string }
| { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; parentToolUseId?: string }
| { type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string }
+ | {
+ type: 'computer_use_permission_request'
+ requestId: string
+ request: ComputerUsePermissionRequest
+ }
| { type: 'message_complete'; usage: TokenUsage }
| { type: 'thinking'; text: string }
| { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number }
@@ -63,6 +73,56 @@ export type TeamMemberStatus = {
currentTask?: string
}
+export type ComputerUseGrantFlags = {
+ clipboardRead: boolean
+ clipboardWrite: boolean
+ systemKeyCombos: boolean
+}
+
+export type ComputerUseResolvedApp = {
+ bundleId: string
+ displayName: string
+ path?: string
+ iconDataUrl?: string
+}
+
+export type ComputerUseResolvedAppRequest = {
+ requestedName: string
+ resolved?: ComputerUseResolvedApp
+ isSentinel: boolean
+ alreadyGranted: boolean
+ proposedTier: 'read' | 'click' | 'full'
+}
+
+export type ComputerUsePermissionRequest = {
+ requestId: string
+ reason: string
+ apps: ComputerUseResolvedAppRequest[]
+ requestedFlags: Partial
+ screenshotFiltering: 'native' | 'none'
+ tccState?: {
+ accessibility: boolean
+ screenRecording: boolean
+ }
+ willHide?: Array<{ bundleId: string; displayName: string }>
+ autoUnhideEnabled?: boolean
+}
+
+export type ComputerUsePermissionResponse = {
+ granted: Array<{
+ bundleId: string
+ displayName: string
+ grantedAt: number
+ tier?: 'read' | 'click' | 'full'
+ }>
+ denied: Array<{
+ bundleId: string
+ reason: 'user_denied' | 'not_installed'
+ }>
+ flags: ComputerUseGrantFlags
+ userConsented?: boolean
+}
+
export type AgentTaskNotification = {
taskId: string
toolUseId: string
diff --git a/runtime/mac_helper.py b/runtime/mac_helper.py
index ceb4fd27..1bfae38e 100755
--- a/runtime/mac_helper.py
+++ b/runtime/mac_helper.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import argparse
import base64
+import ctypes
import json
import os
import subprocess
@@ -175,6 +176,33 @@ def run_osascript(script: str) -> str:
return result.stdout.strip()
+def applescript_modifier(name: str) -> str:
+ if name == "command":
+ return "command down"
+ if name == "option":
+ return "option down"
+ if name == "shift":
+ return "shift down"
+ if name == "ctrl":
+ return "control down"
+ if name == "fn":
+ return "fn down"
+ raise ValueError(f"Unsupported AppleScript modifier: {name}")
+
+
+def send_keystroke_via_osascript(character: str, modifiers: list[str] | None = None) -> None:
+ escaped = character.replace("\\", "\\\\").replace('"', '\\"')
+ if modifiers:
+ modifier_expr = ", ".join(applescript_modifier(m) for m in modifiers)
+ script = (
+ 'tell application "System Events" to keystroke '
+ f'"{escaped}" using {{{modifier_expr}}}'
+ )
+ else:
+ script = f'tell application "System Events" to keystroke "{escaped}"'
+ run_osascript(script)
+
+
def get_displays() -> list[dict[str, Any]]:
max_displays = 32
err, active, count = CGGetActiveDisplayList(max_displays, None, None)
@@ -472,6 +500,10 @@ def write_clipboard(text: str) -> None:
pb.setString_forType_(text, NSPasteboardTypeString)
+def paste_clipboard() -> None:
+ send_keystroke_via_osascript("v", ["command"])
+
+
def detect_screen_recording_permission() -> bool | None:
"""Best-effort passive screen-recording probe with no system prompt.
@@ -520,12 +552,29 @@ def detect_screen_recording_permission() -> bool | None:
return None
-def check_permissions() -> dict[str, bool | None]:
- accessibility = True
+def detect_accessibility_permission() -> bool:
+ """
+ Use the official macOS Accessibility trust API.
+
+ The previous System Events / AppleScript probe was too weak: it could
+ succeed even when the current helper process was not actually trusted for
+ input control, which led the desktop UI to report Accessibility as granted
+ while mouse/keyboard control still failed at runtime.
+ """
+ framework_path = "/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices"
try:
- run_osascript('tell application "System Events" to get name of first process')
+ application_services = ctypes.CDLL(framework_path)
+ application_services.AXIsProcessTrusted.restype = ctypes.c_bool
+ application_services.AXIsProcessTrusted.argtypes = []
+ return bool(application_services.AXIsProcessTrusted())
except Exception:
- accessibility = False
+ # Fail closed: if the trust API can't be queried, treat accessibility
+ # as unavailable instead of reporting a misleading success state.
+ return False
+
+
+def check_permissions() -> dict[str, bool | None]:
+ accessibility = detect_accessibility_permission()
screen_recording = detect_screen_recording_permission()
return {
"accessibility": accessibility,
@@ -559,7 +608,15 @@ def scroll(x: int, y: int, delta_x: int, delta_y: int) -> None:
def key_action(sequence: str, repeat: int = 1) -> None:
parts = [normalize_key(part) for part in sequence.split("+") if part.strip()]
for _ in range(max(1, repeat)):
- if len(parts) == 1:
+ if parts == ["command", "v"]:
+ paste_clipboard()
+ elif parts == ["command", "a"]:
+ send_keystroke_via_osascript("a", ["command"])
+ elif parts == ["command", "c"]:
+ send_keystroke_via_osascript("c", ["command"])
+ elif parts == ["command", "x"]:
+ send_keystroke_via_osascript("x", ["command"])
+ elif len(parts) == 1:
pyautogui.press(parts[0])
else:
pyautogui.hotkey(*parts, interval=0.02)
@@ -703,6 +760,10 @@ def main() -> int:
write_clipboard(str(payload.get("text") or ""))
json_output({"ok": True, "result": True})
return 0
+ if command == "paste_clipboard":
+ paste_clipboard()
+ json_output({"ok": True, "result": True})
+ return 0
error_output(f"Unknown command: {command}", code="bad_command")
return 2
except Exception as exc:
diff --git a/runtime/test_helpers.py b/runtime/test_helpers.py
index 4c5e8db1..540d754c 100644
--- a/runtime/test_helpers.py
+++ b/runtime/test_helpers.py
@@ -154,7 +154,7 @@ class TestJSONProtocol(unittest.TestCase):
"move_mouse", "scroll", "mouse_down", "mouse_up",
"cursor_position", "frontmost_app", "app_under_point",
"list_installed_apps", "list_running_apps", "open_app",
- "read_clipboard", "write_clipboard",
+ "read_clipboard", "write_clipboard", "paste_clipboard",
}
for helper in [MAC_HELPER, WIN_HELPER]:
if not helper.exists():
@@ -247,6 +247,42 @@ class TestWinHelperPermissions(unittest.TestCase):
self.assertIn('"screenRecording": True', func_source)
+class TestMacHelperPermissions(unittest.TestCase):
+ """macOS helper permission detection should use the official trust API."""
+
+ def test_check_permissions_uses_ax_api_instead_of_system_events(self):
+ if not MAC_HELPER.exists():
+ self.skipTest("mac_helper.py not found")
+
+ source = MAC_HELPER.read_text()
+
+ self.assertIn("def detect_accessibility_permission()", source)
+ self.assertIn("AXIsProcessTrusted", source)
+
+ start = source.index("def check_permissions()")
+ rest = source[start:]
+ lines = rest.split("\n")
+ func_lines = [lines[0]]
+ for line in lines[1:]:
+ if line and not line[0].isspace() and not line.startswith("#"):
+ break
+ func_lines.append(line)
+ func_source = "\n".join(func_lines)
+
+ self.assertIn("detect_accessibility_permission()", func_source)
+ self.assertNotIn('tell application "System Events"', func_source)
+
+ def test_clipboard_shortcuts_use_osascript_path(self):
+ if not MAC_HELPER.exists():
+ self.skipTest("mac_helper.py not found")
+
+ source = MAC_HELPER.read_text()
+ self.assertIn("def paste_clipboard()", source)
+ self.assertIn('send_keystroke_via_osascript("v", ["command"])', source)
+ self.assertIn('if parts == ["command", "v"]:', source)
+ self.assertIn('elif parts == ["command", "a"]:', source)
+
+
class TestCrossPlatformFunctions(unittest.TestCase):
"""Test functions that are identical between both helpers."""
@@ -275,7 +311,7 @@ class TestCrossPlatformFunctions(unittest.TestCase):
"""Input action functions (click, scroll, etc.) should be identical."""
if not MAC_HELPER.exists() or not WIN_HELPER.exists():
self.skipTest("Both helpers required")
- for func in ["click", "scroll", "key_action", "hold_keys", "type_text"]:
+ for func in ["click", "scroll", "hold_keys", "type_text"]:
mac_src = self._get_function_body(MAC_HELPER, func)
win_src = self._get_function_body(WIN_HELPER, func)
self.assertEqual(mac_src, win_src,
diff --git a/runtime/win_helper.py b/runtime/win_helper.py
index f6ff848e..cc190ee2 100644
--- a/runtime/win_helper.py
+++ b/runtime/win_helper.py
@@ -513,6 +513,10 @@ def write_clipboard(text: str) -> None:
pyperclip.copy(text)
+def paste_clipboard() -> None:
+ pyautogui.hotkey("ctrl", "v", interval=0.02)
+
+
# ---------------------------------------------------------------------------
# Permissions — Windows doesn't have macOS-style TCC
# ---------------------------------------------------------------------------
@@ -704,6 +708,10 @@ def main() -> int:
write_clipboard(str(payload.get("text") or ""))
json_output({"ok": True, "result": True})
return 0
+ if command == "paste_clipboard":
+ paste_clipboard()
+ json_output({"ok": True, "result": True})
+ return 0
error_output(f"Unknown command: {command}", code="bad_command")
return 2
except Exception as exc:
diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts
index 210846e3..249a3439 100644
--- a/src/server/__tests__/conversation-service.test.ts
+++ b/src/server/__tests__/conversation-service.test.ts
@@ -146,6 +146,19 @@ describe('ConversationService', () => {
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
})
+ test('buildChildEnv injects desktop Computer Use host bundle id for sdk sessions', async () => {
+ const service = new ConversationService() as any
+ const env = (await service.buildChildEnv(
+ '/tmp',
+ 'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
+ )) as Record
+
+ expect(env.CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID).toBe(
+ 'com.claude-code-haha.desktop',
+ )
+ expect(env.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456')
+ })
+
test('uses bun entrypoint fallback on Windows dev mode', () => {
const service = new ConversationService() as any
const args = service.resolveCliArgs(['--print'])
diff --git a/src/server/api/computer-use.ts b/src/server/api/computer-use.ts
index fed13ea0..1650dd10 100644
--- a/src/server/api/computer-use.ts
+++ b/src/server/api/computer-use.ts
@@ -12,6 +12,8 @@ import { access, readFile, mkdir, writeFile } from 'fs/promises'
import { createHash } from 'crypto'
import path from 'path'
import { fileURLToPath } from 'url'
+import type { CuPermissionRequest } from '../../vendor/computer-use-mcp/types.js'
+import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
// Embed helper scripts at compile time so they're available in bundled mode
// @ts-ignore — Bun text import
import MAC_HELPER_CONTENT from '../../../runtime/mac_helper.py' with { type: 'text' }
@@ -345,6 +347,11 @@ type ComputerUseConfig = {
}
}
+type RequestAccessBody = {
+ sessionId?: string
+ request?: CuPermissionRequest
+}
+
const DEFAULT_CONFIG: ComputerUseConfig = {
authorizedApps: [],
grantFlags: { clipboardRead: true, clipboardWrite: true, systemKeyCombos: true },
@@ -453,6 +460,29 @@ export async function handleComputerUseApi(
return Response.json({ ok: true })
}
+ if (action === 'request-access' && req.method === 'POST') {
+ try {
+ const body = (await req.json()) as RequestAccessBody
+ if (!body.sessionId || !body.request?.requestId) {
+ return Response.json(
+ { error: 'BAD_REQUEST', message: 'sessionId and request are required' },
+ { status: 400 },
+ )
+ }
+
+ const response = await computerUseApprovalService.requestApproval(
+ body.sessionId,
+ body.request,
+ )
+ return Response.json(response)
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : 'Computer Use approval failed'
+ const status = message.includes('not connected') ? 409 : 500
+ return Response.json({ error: 'COMPUTER_USE_APPROVAL_FAILED', message }, { status })
+ }
+ }
+
return Response.json(
{ error: 'NOT_FOUND', message: `Unknown computer-use action: ${action}` },
{ status: 404 },
diff --git a/src/server/services/computerUseApprovalService.ts b/src/server/services/computerUseApprovalService.ts
new file mode 100644
index 00000000..055c4efb
--- /dev/null
+++ b/src/server/services/computerUseApprovalService.ts
@@ -0,0 +1,73 @@
+import type { CuPermissionRequest, CuPermissionResponse } from '../../vendor/computer-use-mcp/types.js'
+import { sendToSession } from '../ws/handler.js'
+
+type PendingApproval = {
+ sessionId: string
+ resolve: (response: CuPermissionResponse) => void
+ reject: (error: Error) => void
+ timeout: ReturnType
+}
+
+const REQUEST_TIMEOUT_MS = 5 * 60 * 1000
+
+class ComputerUseApprovalService {
+ private pending = new Map()
+
+ async requestApproval(
+ sessionId: string,
+ request: CuPermissionRequest,
+ ): Promise {
+ const existing = this.pending.get(request.requestId)
+ if (existing) {
+ clearTimeout(existing.timeout)
+ existing.reject(new Error('Computer Use approval request superseded'))
+ this.pending.delete(request.requestId)
+ }
+
+ return await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ this.pending.delete(request.requestId)
+ reject(new Error('Computer Use approval timed out'))
+ }, REQUEST_TIMEOUT_MS)
+
+ this.pending.set(request.requestId, {
+ sessionId,
+ resolve,
+ reject,
+ timeout,
+ })
+
+ const sent = sendToSession(sessionId, {
+ type: 'computer_use_permission_request',
+ requestId: request.requestId,
+ request,
+ })
+
+ if (!sent) {
+ clearTimeout(timeout)
+ this.pending.delete(request.requestId)
+ reject(new Error('Desktop session is not connected'))
+ }
+ })
+ }
+
+ resolveApproval(requestId: string, response: CuPermissionResponse): boolean {
+ const pending = this.pending.get(requestId)
+ if (!pending) return false
+ clearTimeout(pending.timeout)
+ this.pending.delete(requestId)
+ pending.resolve(response)
+ return true
+ }
+
+ cancelSession(sessionId: string): void {
+ for (const [requestId, pending] of this.pending.entries()) {
+ if (pending.sessionId !== sessionId) continue
+ clearTimeout(pending.timeout)
+ this.pending.delete(requestId)
+ pending.reject(new Error('Desktop session disconnected during Computer Use approval'))
+ }
+ }
+}
+
+export const computerUseApprovalService = new ComputerUseApprovalService()
diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts
index 2ae4dd62..f76a16e8 100644
--- a/src/server/services/conversationService.ts
+++ b/src/server/services/conversationService.ts
@@ -119,7 +119,7 @@ export class ConversationService {
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts
// chdir 后落到正确目录。
//
- const childEnv = await this.buildChildEnv(workDir)
+ const childEnv = await this.buildChildEnv(workDir, sdkUrl)
let proc: ReturnType
try {
@@ -471,7 +471,10 @@ export class ConversationService {
return args
}
- private async buildChildEnv(workDir: string): Promise> {
+ private async buildChildEnv(
+ workDir: string,
+ sdkUrl?: string,
+ ): Promise> {
// Provider isolation: when Desktop has its own provider config/index,
// strip inherited provider env vars so the child CLI reads fresh values
// from ~/.claude/cc-haha/settings.json instead of stale process.env.
@@ -497,11 +500,27 @@ export class ConversationService {
}
}
+ let desktopServerUrl: string | undefined
+ if (sdkUrl) {
+ try {
+ const parsed = new URL(sdkUrl)
+ desktopServerUrl = `http://${parsed.host}`
+ } catch {
+ desktopServerUrl = undefined
+ }
+ }
+
return {
...cleanEnv,
CLAUDE_CODE_ENABLE_TASKS: '1',
CALLER_DIR: workDir,
PWD: workDir,
+ ...(sdkUrl
+ ? { CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID: 'com.claude-code-haha.desktop' }
+ : {}),
+ ...(desktopServerUrl
+ ? { CC_HAHA_DESKTOP_SERVER_URL: desktopServerUrl }
+ : {}),
// Tell the CLI entrypoint to skip project .env loading. Provider env
// should come from Desktop-managed config or inherited launch env, not
// be reintroduced from the repo's .env file.
diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts
index 508740ec..56533cdc 100644
--- a/src/server/ws/events.ts
+++ b/src/server/ws/events.ts
@@ -11,6 +11,11 @@
export type ClientMessage =
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
| { type: 'permission_response'; requestId: string; allowed: boolean; rule?: string }
+ | {
+ type: 'computer_use_permission_response'
+ requestId: string
+ response: ComputerUsePermissionResponse
+ }
| { type: 'set_permission_mode'; mode: string }
| { type: 'stop_generation' }
| { type: 'ping' }
@@ -34,6 +39,11 @@ export type ServerMessage =
| { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown; parentToolUseId?: string }
| { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; parentToolUseId?: string }
| { type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string }
+ | {
+ type: 'computer_use_permission_request'
+ requestId: string
+ request: ComputerUsePermissionRequest
+ }
| { type: 'message_complete'; usage: TokenUsage }
| { type: 'thinking'; text: string }
| { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number }
@@ -62,6 +72,56 @@ export type TeamMemberStatus = {
currentTask?: string
}
+export type ComputerUseGrantFlags = {
+ clipboardRead: boolean
+ clipboardWrite: boolean
+ systemKeyCombos: boolean
+}
+
+export type ComputerUseResolvedApp = {
+ bundleId: string
+ displayName: string
+ path?: string
+ iconDataUrl?: string
+}
+
+export type ComputerUseResolvedAppRequest = {
+ requestedName: string
+ resolved?: ComputerUseResolvedApp
+ isSentinel: boolean
+ alreadyGranted: boolean
+ proposedTier: 'read' | 'click' | 'full'
+}
+
+export type ComputerUsePermissionRequest = {
+ requestId: string
+ reason: string
+ apps: ComputerUseResolvedAppRequest[]
+ requestedFlags: Partial
+ screenshotFiltering: 'native' | 'none'
+ tccState?: {
+ accessibility: boolean
+ screenRecording: boolean
+ }
+ willHide?: Array<{ bundleId: string; displayName: string }>
+ autoUnhideEnabled?: boolean
+}
+
+export type ComputerUsePermissionResponse = {
+ granted: Array<{
+ bundleId: string
+ displayName: string
+ grantedAt: number
+ tier?: 'read' | 'click' | 'full'
+ }>
+ denied: Array<{
+ bundleId: string
+ reason: 'user_denied' | 'not_installed'
+ }>
+ flags: ComputerUseGrantFlags
+ userConsented?: boolean
+}
+
// ============================================================================
// Internal types
// ============================================================================
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index 20986103..19fa3dcf 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -13,6 +13,7 @@ import {
ConversationStartupError,
conversationService,
} from '../services/conversationService.js'
+import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
import { sessionService } from '../services/sessionService.js'
import { SettingsService } from '../services/settingsService.js'
import { ProviderService } from '../services/providerService.js'
@@ -118,6 +119,10 @@ export const handleWebSocket = {
handlePermissionResponse(ws, message)
break
+ case 'computer_use_permission_response':
+ handleComputerUsePermissionResponse(ws, message)
+ break
+
case 'set_permission_mode':
handleSetPermissionMode(ws, message)
break
@@ -148,6 +153,7 @@ export const handleWebSocket = {
}
console.log(`[WS] Client disconnected from session: ${sessionId} (${code}: ${reason})`)
+ computerUseApprovalService.cancelSession(sessionId)
activeSessions.delete(sessionId)
cleanupStreamState(sessionId)
sessionSlashCommands.delete(sessionId)
@@ -300,6 +306,22 @@ function handlePermissionResponse(
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
}
+function handleComputerUsePermissionResponse(
+ ws: ServerWebSocket,
+ message: Extract
+) {
+ const { sessionId } = ws.data
+ const ok = computerUseApprovalService.resolveApproval(
+ message.requestId,
+ message.response,
+ )
+ if (!ok) {
+ console.warn(
+ `[WS] Ignored Computer Use permission response for unknown request ${message.requestId} from ${sessionId}`
+ )
+ }
+}
+
function handleSetPermissionMode(
ws: ServerWebSocket,
message: Extract
diff --git a/src/utils/computerUse/executor.ts b/src/utils/computerUse/executor.ts
index 7f6b4f59..2fedee66 100644
--- a/src/utils/computerUse/executor.ts
+++ b/src/utils/computerUse/executor.ts
@@ -15,13 +15,14 @@ import type {
ScreenshotResult,
} from '../../vendor/computer-use-mcp/index.js'
import { API_RESIZE_PARAMS, targetImageSize } from '../../vendor/computer-use-mcp/index.js'
-import { execFileNoThrow } from '../execFileNoThrow.js'
import { sleep } from '../sleep.js'
import { CLI_CU_CAPABILITIES, CLI_HOST_BUNDLE_ID } from './common.js'
import { callPythonHelper } from './pythonBridge.js'
const SCREENSHOT_JPEG_QUALITY = 0.75
const MOVE_SETTLE_MS = 50
+const hostBundleId =
+ process.env.CC_HAHA_COMPUTER_USE_HOST_BUNDLE_ID || CLI_HOST_BUNDLE_ID
type PythonDisplayGeometry = DisplayGeometry
@@ -48,14 +49,11 @@ function normalizeDisplayGeometry(display: PythonDisplayGeometry): DisplayGeomet
}
async function readClipboardViaPbpaste(): Promise {
- const { stdout, code } = await execFileNoThrow('pbpaste', [], { useCwd: false })
- if (code !== 0) throw new Error(`pbpaste exited with code ${code}`)
- return stdout
+ return callPythonHelper('read_clipboard', {})
}
async function writeClipboardViaPbcopy(text: string): Promise {
- const { code } = await execFileNoThrow('pbcopy', [], { input: text, useCwd: false })
- if (code !== 0) throw new Error(`pbcopy exited with code ${code}`)
+ await callPythonHelper('write_clipboard', { text })
}
async function typeViaClipboard(text: string): Promise {
@@ -66,8 +64,11 @@ async function typeViaClipboard(text: string): Promise {
try {
await writeClipboardViaPbcopy(text)
- await callPythonHelper('key', { keySequence: 'command+v', repeat: 1 })
- await sleep(100)
+ // Give NSPasteboard a beat before paste, then keep the new contents
+ // resident long enough for Electron/WebView fields to consume them.
+ await sleep(40)
+ await callPythonHelper('paste_clipboard', {})
+ await sleep(180)
} finally {
if (typeof saved === 'string') {
try {
@@ -88,7 +89,7 @@ export function createCliExecutor(_opts: {
return {
capabilities: {
...CLI_CU_CAPABILITIES,
- hostBundleId: CLI_HOST_BUNDLE_ID,
+ hostBundleId,
},
async prepareForAction(): Promise {
diff --git a/src/utils/computerUse/wrapper.tsx b/src/utils/computerUse/wrapper.tsx
index 9b336cfb..783d90b7 100644
--- a/src/utils/computerUse/wrapper.tsx
+++ b/src/utils/computerUse/wrapper.tsx
@@ -51,6 +51,7 @@ type Binding = {
*/
let binding: Binding | undefined;
let currentToolUseContext: ToolUseContext | undefined;
+const desktopServerUrl = process.env.CC_HAHA_DESKTOP_SERVER_URL;
function tuc(): ToolUseContext {
// Safe: `binding` is only populated when `currentToolUseContext` is set.
// Called only from within `ctx` callbacks, which only fire during dispatch.
@@ -230,6 +231,37 @@ export function buildSessionContext(): ComputerUseSessionContext {
formatLockHeldMessage: formatLockHeld
};
}
+
+async function runDesktopPermissionDialog(
+ req: CuPermissionRequest,
+ signal: AbortSignal,
+): Promise {
+ if (!desktopServerUrl) {
+ throw new Error('Desktop server URL is not configured')
+ }
+
+ const response = await fetch(`${desktopServerUrl}/api/computer-use/request-access`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ sessionId: getSessionId(),
+ request: req
+ }),
+ signal
+ })
+
+ if (!response.ok) {
+ const message = await response.text().catch(() => '')
+ throw new Error(
+ `Desktop Computer Use approval failed (${response.status}): ${message || response.statusText}`,
+ )
+ }
+
+ return await response.json() as CuPermissionResponse
+}
+
/**
* Load pre-authorized apps from ~/.claude/cc-haha/computer-use-config.json.
* Called once when the binding is first created. Pre-authorized apps
@@ -362,8 +394,20 @@ export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCP
*/
async function runPermissionDialog(req: CuPermissionRequest): Promise {
const context = tuc();
+ let desktopBridgeError: Error | undefined;
+ if (desktopServerUrl) {
+ try {
+ return await runDesktopPermissionDialog(req, context.abortController.signal);
+ } catch (error) {
+ desktopBridgeError = error instanceof Error ? error : new Error(String(error));
+ logForDebugging(`[Computer Use] Desktop approval bridge failed: ${desktopBridgeError.message}`);
+ }
+ }
const setToolJSX = context.setToolJSX;
if (!setToolJSX) {
+ if (desktopBridgeError) {
+ throw desktopBridgeError;
+ }
// Shouldn't happen — main.tsx gate excludes non-interactive. Fail safe.
return {
granted: [],
@@ -400,4 +444,4 @@ async function runPermissionDialog(req: CuPermissionRequest): Promise 1)) &&
+ (adapter.executor.capabilities.platform === "darwin" &&
+ hasNonControlText)) &&
overrides.grantFlags.clipboardWrite &&
subGates.clipboardPasteMultiline;