Merge branch 'codex/computer-use-desktop-fixes'

This commit is contained in:
程序员阿江(Relakkes) 2026-04-20 12:12:17 +08:00
commit 4bd30052ff
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

@ -337,6 +337,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

@ -339,6 +339,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;