Prevent desktop Computer Use from stalling on missing approvals and unstable text input

Desktop sessions were missing a visible request_access approval path and could
mis-detect their own app window as an unapproved frontmost target, which caused
Computer Use clicks to fail even after opening the intended app. On macOS, text
entry was also split across inconsistent clipboard and keystroke paths, making
Electron inputs unreliable for Chinese and short strings.

This change adds a desktop approval bridge over the existing session websocket,
renders a dedicated desktop approval modal, threads the real desktop bundle id
into the Computer Use executor, and switches macOS clipboard typing onto the
native pasteboard plus system paste shortcut path. It also makes tool error
results expandable in the desktop chat UI so frontmost-gate failures are fully
visible during debugging.

Constraint: Desktop sessions run the CLI over the SDK websocket path, so Ink tool JSX dialogs are not visible there
Constraint: macOS IME and Electron text inputs are unreliable with pyautogui.write and generic hotkey synthesis
Rejected: Reuse CLI setToolJSX dialogs in desktop mode | no transport for mid-call Ink UI over the SDK bridge
Rejected: Keep shell pbcopy/pbpaste for clipboard typing | inconsistent with NSPasteboard path and less reliable for Chinese text
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep desktop Computer Use approvals and macOS text-entry behavior on a single bridge/path; avoid reintroducing separate CLI-only and desktop-only codepaths for the same action
Tested: python3 -m unittest runtime/test_helpers.py
Tested: bun test src/utils/computerUse/permissions.test.ts src/server/__tests__/conversation-service.test.ts
Tested: cd desktop && bun run test ComputerUsePermissionModal chatStore
Tested: cd desktop && bun run test chatBlocks
Tested: cd desktop && bun run lint
Not-tested: End-to-end manual Computer Use interaction against a live Electron target app on macOS
This commit is contained in:
程序员阿江(Relakkes) 2026-04-20 12:12:02 +08:00
parent c904178518
commit 5cd6b5d07b
26 changed files with 1217 additions and 26 deletions

View File

@ -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: '',

View File

@ -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(
<ComputerUsePermissionModal
sessionId="session-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',
},
{
requestedName: 'Missing App',
isSentinel: false,
alreadyGranted: false,
proposedTier: 'full',
},
],
requestedFlags: {
clipboardRead: true,
systemKeyCombos: true,
},
screenshotFiltering: 'native',
willHide: [{ bundleId: 'com.apple.TextEdit', displayName: 'TextEdit' }],
autoUnhideEnabled: true,
}}
/>,
)
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(
<ComputerUsePermissionModal
sessionId="session-1"
request={{
requestId: 'cu-1',
reason: '',
apps: [],
requestedFlags: {},
screenshotFiltering: 'native',
tccState: {
accessibility: false,
screenRecording: true,
},
}}
/>,
)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open Accessibility' }))
})
expect(openSettingsMock).toHaveBeenCalledWith('Privacy_Accessibility')
})
})

View File

@ -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 (
<Modal
open
onClose={handleDeny}
title={
tccState
? t('computerUseApproval.titleTcc')
: t('computerUseApproval.titleApps')
}
width={640}
footer={
tccState ? (
<Button variant="ghost" onClick={handleDeny}>
{t('computerUseApproval.deny')}
</Button>
) : (
<>
<Button variant="ghost" onClick={handleDeny}>
{t('computerUseApproval.deny')}
</Button>
<Button variant="primary" onClick={handleAllow}>
{t('computerUseApproval.allow')}
</Button>
</>
)
}
>
{tccState ? (
<div className="space-y-4">
<p className="text-sm text-[var(--color-text-secondary)]">
{t('computerUseApproval.tccHint')}
</p>
<div className="space-y-3">
<PermissionRow
label={t('computerUseApproval.accessibility')}
granted={tccState.accessibility}
actionLabel={t('computerUseApproval.openAccessibility')}
actionLoading={openingPane === 'Privacy_Accessibility'}
onAction={() => openSettings('Privacy_Accessibility')}
/>
<PermissionRow
label={t('computerUseApproval.screenRecording')}
granted={tccState.screenRecording}
actionLabel={t('computerUseApproval.openScreenRecording')}
actionLoading={openingPane === 'Privacy_ScreenCapture'}
onAction={() => openSettings('Privacy_ScreenCapture')}
/>
</div>
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3 text-xs text-[var(--color-text-tertiary)]">
{t('computerUseApproval.tryAgainHint')}
</div>
<div className="flex justify-end">
<Button variant="secondary" onClick={handleDeny}>
{t('computerUseApproval.tryAgain')}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
{request.reason ? (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">
{t('computerUseApproval.reason')}
</div>
<div className="mt-1 text-sm text-[var(--color-text-primary)]">
{request.reason}
</div>
</div>
) : null}
<div className="space-y-2">
{request.apps.map((app) => {
const resolved = app.resolved
return (
<div
key={resolved?.bundleId ?? app.requestedName}
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
{resolved?.displayName ?? app.requestedName}
</div>
<div className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{resolved?.bundleId ?? t('computerUseApproval.notInstalled')}
</div>
</div>
<span className="rounded-full bg-[var(--color-surface-container)] px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-secondary)]">
{app.proposedTier}
</span>
</div>
{!resolved ? (
<p className="mt-2 text-xs text-[var(--color-error)]">
{t('computerUseApproval.notInstalled')}
</p>
) : null}
{app.alreadyGranted ? (
<p className="mt-2 text-xs text-[var(--color-success)]">
{t('computerUseApproval.alreadyGranted')}
</p>
) : null}
{app.isSentinel ? (
<p className="mt-2 text-xs text-[var(--color-warning)]">
{t('computerUseApproval.sensitiveApp')}
</p>
) : null}
</div>
)
})}
</div>
{requestedFlags.length > 0 ? (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--color-text-tertiary)]">
{t('computerUseApproval.alsoRequested')}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{requestedFlags.map((flag) => (
<span
key={flag}
className="rounded-full bg-[var(--color-surface-container)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
>
{flag}
</span>
))}
</div>
</div>
) : null}
{request.willHide && request.willHide.length > 0 ? (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3 text-sm text-[var(--color-text-secondary)]">
{request.autoUnhideEnabled
? t('computerUseApproval.hideWhileWorkingRestore', {
count: request.willHide.length,
})
: t('computerUseApproval.hideWhileWorking', {
count: request.willHide.length,
})}
</div>
) : null}
</div>
)}
</Modal>
)
}
function PermissionRow({
label,
granted,
actionLabel,
actionLoading,
onAction,
}: {
label: string
granted: boolean
actionLabel: string
actionLoading: boolean
onAction: () => void
}) {
const t = useTranslation()
return (
<div className="flex items-center justify-between gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div>
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
{label}
</div>
<div className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{granted
? t('computerUseApproval.granted')
: t('computerUseApproval.notGranted')}
</div>
</div>
{!granted ? (
<Button
variant="secondary"
size="sm"
loading={actionLoading}
onClick={onAction}
>
{actionLabel}
</Button>
) : null}
</div>
)
}

View File

@ -19,6 +19,7 @@ function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionS
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',

View File

@ -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 (
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
@ -76,7 +77,7 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
{outputSummary}
</span>
)}
{result?.isError && (
{result?.isError && (
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
)}
{expandable && (

View File

@ -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(
<ToolCallBlock
toolName="mcp__computer-use__left_click"
input={{ coordinate: [120, 220] }}
result={{
content: '"Claude Code Haha" is not in the allowed applications and is currently in front. Take a new screenshot — it may have appeared since your last one.',
isError: true,
}}
/>,
)
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: '',

View File

@ -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',

View File

@ -333,6 +333,28 @@ export const zh: Record<TranslationKey, string> = {
'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': '已回答',

View File

@ -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: '',

View File

@ -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() {
<TeamStatusBar />
<ChatInput />
{!isMemberSession && activeTabId ? (
<ComputerUsePermissionModal
sessionId={activeTabId}
request={pendingComputerUsePermission?.request ?? null}
/>
) : null}
</div>
)
}

View File

@ -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: '',

View File

@ -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<void>
@ -272,6 +284,20 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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')
{

View File

@ -27,6 +27,7 @@ function createMemberSessionState() {
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',

View File

@ -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<ComputerUseGrantFlags>
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

View File

@ -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:

View File

@ -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,

View File

@ -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:

View File

@ -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<string, string>
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'])

View File

@ -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 },

View File

@ -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<typeof setTimeout>
}
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000
class ComputerUseApprovalService {
private pending = new Map<string, PendingApproval>()
async requestApproval(
sessionId: string,
request: CuPermissionRequest,
): Promise<CuPermissionResponse> {
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<CuPermissionResponse>((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()

View File

@ -119,7 +119,7 @@ export class ConversationService {
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。
//
const childEnv = await this.buildChildEnv(workDir)
const childEnv = await this.buildChildEnv(workDir, sdkUrl)
let proc: ReturnType<typeof Bun.spawn>
try {
@ -471,7 +471,10 @@ export class ConversationService {
return args
}
private async buildChildEnv(workDir: string): Promise<Record<string, string>> {
private async buildChildEnv(
workDir: string,
sdkUrl?: string,
): Promise<Record<string, string>> {
// 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.

View File

@ -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<ComputerUseGrantFlags>
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
// ============================================================================

View File

@ -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<WebSocketData>,
message: Extract<ClientMessage, { type: 'computer_use_permission_response' }>
) {
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<WebSocketData>,
message: Extract<ClientMessage, { type: 'set_permission_mode' }>

View File

@ -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<string> {
const { stdout, code } = await execFileNoThrow('pbpaste', [], { useCwd: false })
if (code !== 0) throw new Error(`pbpaste exited with code ${code}`)
return stdout
return callPythonHelper<string>('read_clipboard', {})
}
async function writeClipboardViaPbcopy(text: string): Promise<void> {
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<void> {
@ -66,8 +64,11 @@ async function typeViaClipboard(text: string): Promise<void> {
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<string[]> {

File diff suppressed because one or more lines are too long

View File

@ -2433,9 +2433,11 @@ async function handleType(
// IME/input-source state can corrupt keystroke-by-keystroke typing even for
// plain ASCII. The save/restore + read-back-verify lives in the EXECUTOR,
// not here. Here we just route.
const hasNonControlText = /[^\r\n\t]/u.test(text);
const viaClipboard =
(text.includes("\n") ||
(adapter.executor.capabilities.platform === "darwin" && text.length > 1)) &&
(adapter.executor.capabilities.platform === "darwin" &&
hasNonControlText)) &&
overrides.grantFlags.clipboardWrite &&
subGates.clipboardPasteMultiline;