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;