mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge branch 'codex/computer-use-desktop-fixes'
This commit is contained in:
commit
4bd30052ff
@ -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: '',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '已回答',
|
||||
|
||||
@ -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