mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
c904178518
commit
5cd6b5d07b
@ -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: '',
|
||||
|
||||
174
desktop/src/components/chat/ComputerUsePermissionModal.test.tsx
Normal file
174
desktop/src/components/chat/ComputerUsePermissionModal.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
311
desktop/src/components/chat/ComputerUsePermissionModal.tsx
Normal file
311
desktop/src/components/chat/ComputerUsePermissionModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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: '',
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '已回答',
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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')
|
||||
{
|
||||
|
||||
@ -27,6 +27,7 @@ function createMemberSessionState() {
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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 },
|
||||
|
||||
73
src/server/services/computerUseApprovalService.ts
Normal file
73
src/server/services/computerUseApprovalService.ts
Normal 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()
|
||||
@ -119,7 +119,7 @@ export class ConversationService {
|
||||
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts
|
||||
// chdir 后落到正确目录。
|
||||
//
|
||||
const childEnv = await this.buildChildEnv(workDir)
|
||||
const childEnv = await this.buildChildEnv(workDir, sdkUrl)
|
||||
|
||||
let proc: ReturnType<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.
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -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' }>
|
||||
|
||||
@ -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
4
src/vendor/computer-use-mcp/toolCalls.ts
vendored
4
src/vendor/computer-use-mcp/toolCalls.ts
vendored
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user