Merge commit '1cd90dc'

This commit is contained in:
程序员阿江(Relakkes) 2026-05-06 11:40:59 +08:00
commit c0fe61b665
20 changed files with 501 additions and 63 deletions

View File

@ -46,6 +46,7 @@ export type AuthorizedApp = {
}
export type ComputerUseConfig = {
enabled: boolean
authorizedApps: AuthorizedApp[]
grantFlags: {
clipboardRead: boolean

View File

@ -574,6 +574,8 @@ export const en = {
'settings.tab.computerUse': 'Computer Use',
'settings.computerUse.title': 'Computer Use',
'settings.computerUse.description': 'Allow Claude to take screenshots, click, type, and control your computer. Requires Python 3. On macOS, accessibility permissions are also needed.',
'settings.computerUse.enabledToggle': 'Enabled',
'settings.computerUse.disabledHint': 'Computer Use is off. New sessions will not inject the computer-use MCP server or expose desktop-control tools to the Coding Agent.',
'settings.computerUse.notSupported': 'Computer Use is only supported on macOS and Windows.',
'settings.computerUse.python': 'Python 3',
'settings.computerUse.pythonNotFound': 'Not installed. Please install Python 3 first.',

View File

@ -576,6 +576,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.tab.computerUse': 'Computer Use',
'settings.computerUse.title': 'Computer Use',
'settings.computerUse.description': '允许 Claude 截屏、点击、打字并控制你的电脑。需要 Python 3macOS 上还需要辅助功能权限。',
'settings.computerUse.enabledToggle': '启用',
'settings.computerUse.disabledHint': 'Computer Use 已关闭。新会话不会注入 computer-use MCP也不会把桌面控制工具暴露给 Coding Agent。',
'settings.computerUse.notSupported': 'Computer Use 仅支持 macOS 和 Windows。',
'settings.computerUse.python': 'Python 3',
'settings.computerUse.pythonNotFound': '未安装,请先安装 Python 3。',

View File

@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { ComputerUseSettings } from './ComputerUseSettings'
import { useSettingsStore } from '../stores/settingsStore'
const computerUseApiMock = vi.hoisted(() => ({
getStatus: vi.fn(),
getInstalledApps: vi.fn(),
getAuthorizedApps: vi.fn(),
setAuthorizedApps: vi.fn(),
runSetup: vi.fn(),
openSettings: vi.fn(),
}))
vi.mock('../api/computerUse', () => ({
computerUseApi: computerUseApiMock,
}))
const readyStatus = {
platform: 'darwin',
supported: true,
python: {
installed: true,
version: '3.12.0',
path: '/usr/bin/python3',
},
venv: {
created: false,
path: '/tmp/venv',
},
dependencies: {
installed: false,
requirementsFound: true,
},
permissions: {
accessibility: null,
screenRecording: null,
},
}
const enabledConfig = {
enabled: true,
authorizedApps: [],
grantFlags: {
clipboardRead: true,
clipboardWrite: true,
systemKeyCombos: true,
},
}
describe('ComputerUseSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
computerUseApiMock.getStatus.mockReset()
computerUseApiMock.getInstalledApps.mockReset()
computerUseApiMock.getAuthorizedApps.mockReset()
computerUseApiMock.setAuthorizedApps.mockReset()
computerUseApiMock.runSetup.mockReset()
computerUseApiMock.openSettings.mockReset()
computerUseApiMock.getStatus.mockResolvedValue(readyStatus)
computerUseApiMock.getAuthorizedApps.mockResolvedValue(enabledConfig)
computerUseApiMock.setAuthorizedApps.mockResolvedValue({ ok: true })
})
it('renders the stored disabled state with the MCP exposure hint', async () => {
computerUseApiMock.getAuthorizedApps.mockResolvedValue({
...enabledConfig,
enabled: false,
})
render(<ComputerUseSettings />)
const toggle = await screen.findByLabelText('Enabled')
await waitFor(() => expect(toggle).not.toBeChecked())
expect(
screen.getByText(/will not inject the computer-use MCP server/i),
).toBeInTheDocument()
})
it('saves the Computer Use enablement toggle independently', async () => {
render(<ComputerUseSettings />)
const toggle = await screen.findByLabelText('Enabled')
await waitFor(() => expect(computerUseApiMock.getAuthorizedApps).toHaveBeenCalled())
await act(async () => {
fireEvent.click(toggle)
await Promise.resolve()
})
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
enabled: false,
})
})
})

View File

@ -58,6 +58,7 @@ export function ComputerUseSettings() {
const [appsLoading, setAppsLoading] = useState(false)
const [appsSaved, setAppsSaved] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [computerUseEnabled, setComputerUseEnabled] = useState(true)
const [clipboardAccess, setClipboardAccess] = useState(true)
const [systemKeys, setSystemKeys] = useState(true)
@ -72,6 +73,22 @@ export function ComputerUseSettings() {
}
}, [])
const applyConfig = useCallback((configResult: Awaited<ReturnType<typeof computerUseApi.getAuthorizedApps>>) => {
setComputerUseEnabled(configResult.enabled)
setAuthorizedApps(configResult.authorizedApps)
setAuthorizedBundleIds(new Set(configResult.authorizedApps.map(a => a.bundleId)))
setClipboardAccess(configResult.grantFlags.clipboardRead)
setSystemKeys(configResult.grantFlags.systemKeyCombos)
}, [])
const fetchConfig = useCallback(async () => {
try {
applyConfig(await computerUseApi.getAuthorizedApps())
} catch {
// API not ready
}
}, [applyConfig])
const fetchApps = useCallback(async () => {
setAppsLoading(true)
try {
@ -80,20 +97,18 @@ export function ComputerUseSettings() {
computerUseApi.getAuthorizedApps(),
])
setInstalledApps(appsResult.apps)
setAuthorizedApps(configResult.authorizedApps)
setAuthorizedBundleIds(new Set(configResult.authorizedApps.map(a => a.bundleId)))
setClipboardAccess(configResult.grantFlags.clipboardRead)
setSystemKeys(configResult.grantFlags.systemKeyCombos)
applyConfig(configResult)
} catch {
// API not ready
} finally {
setAppsLoading(false)
}
}, [])
}, [applyConfig])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
fetchConfig()
}, [fetchStatus, fetchConfig])
// Load apps when environment is ready
const envReady = status?.venv.created && status?.dependencies.installed
@ -157,6 +172,14 @@ export function ComputerUseSettings() {
})
}
const toggleComputerUseEnabled = (value: boolean) => {
setComputerUseEnabled(value)
computerUseApi.setAuthorizedApps({ enabled: value }).then(() => {
setAppsSaved(true)
setTimeout(() => setAppsSaved(false), 1500)
})
}
const allReady =
status?.supported &&
status.python.installed &&
@ -193,14 +216,31 @@ export function ComputerUseSettings() {
<div className="max-w-2xl space-y-6">
{/* Title */}
<div>
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.computerUse.title')}
</h2>
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.computerUse.title')}
</h2>
<label className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] cursor-pointer">
<input
type="checkbox"
checked={computerUseEnabled}
onChange={e => toggleComputerUseEnabled(e.target.checked)}
className="rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
{t('settings.computerUse.enabledToggle')}
</label>
</div>
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
{t('settings.computerUse.description')}
</p>
</div>
{!computerUseEnabled && (
<div className="px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-sm text-yellow-700">
{t('settings.computerUse.disabledHint')}
</div>
)}
{checkState === 'loading' ? (
<div className="py-8 text-center text-sm text-[var(--color-text-tertiary)]">
{t('common.loading')}

View File

@ -1,7 +1,7 @@
# Computer Use Guide
> **Modified Version**: This feature is a **heavily modified version** of the Computer Use (internal codename "Chicago") found in the leaked Claude Code source. The official implementation relies on Anthropic's private native modules (`@ant/computer-use-swift`, `@ant/computer-use-input`) that are not publicly available. We **replaced the entire underlying operation layer** with a Python bridge (`pyautogui` + `mss` + `pyobjc`), enabling anyone to run Computer Use on macOS.
> **Modified Version**: This feature is a **heavily modified version** of the Computer Use (internal codename "Chicago") found in the leaked Claude Code source. The official implementation relies on Anthropic's private native modules (`@ant/computer-use-swift`, `@ant/computer-use-input`) that are not publicly available. We **replaced the entire underlying operation layer** with a Python bridge: macOS uses `pyautogui` + `mss` + `pyobjc`, and Windows uses `pyautogui` + `mss` + `win32gui` + `psutil`.
---
@ -45,7 +45,7 @@ Computer Use allows AI models to **directly control your computer** — taking s
|----------|-------------|--------|-------|
| macOS | Apple Silicon (M1/M2/M3/M4) | ✅ Fully supported | Recommended |
| macOS | Intel x86_64 | ✅ Fully supported | |
| Windows | Any | ⚠️ Theoretically possible | Core libs (`pyautogui` + `mss`) are cross-platform, but `pyobjc` parts (app management) need to be replaced with `win32com`. Not yet adapted |
| Windows | x64 | ✅ Fully supported | Uses `win32gui` + `psutil` + `pyperclip` + `screeninfo` instead of macOS APIs |
| Linux | Any | ⚠️ Theoretically possible | Same as above — `pyobjc` needs to be replaced with `wmctrl` + `xdotool`. Not yet adapted |
### Requirements
@ -53,6 +53,7 @@ Computer Use allows AI models to **directly control your computer** — taking s
- [Bun](https://bun.sh) >= 1.1.0
- Python >= 3.8 (venv and dependencies are auto-installed on first use)
- macOS permissions: Accessibility + Screen Recording
- Windows: no extra OS permission setup
---
@ -83,10 +84,12 @@ Computer Use operates through a **screenshot → analyze → act** feedback loop
│ callPythonHelper()
┌────────────────────────────────────────────────────┐
│ Python Bridge (runtime/mac_helper.py) │
│ Python Bridge │
│ macOS: runtime/mac_helper.py │
│ Windows: runtime/win_helper.py │
│ pyautogui.click(756, 342) ← mouse control │
│ mss.grab(monitor) ← screenshot │
│ NSWorkspace.open(bundleId) ← app management │
│ NSWorkspace / win32gui ← app management │
└────────────────────────────────────────────────────┘
```
@ -144,6 +147,25 @@ Just ask in natural language:
> Type "hello" in the text editor
```
### Disable Computer Use
If you only want the regular Coding Agent and do not want to expose `computer-use` MCP tools, disable it with either command:
```bash
claude-haha --no-computer-use
CLAUDE_COMPUTER_USE_ENABLED=0 claude-haha
```
You can also write the global config file at `~/.claude/cc-haha/computer-use-config.json`:
```json
{
"enabled": false
}
```
The desktop Settings > Computer Use switch writes the same config. Once disabled, new sessions will not inject the dynamic `computer-use` MCP server or add its desktop-control tools to `allowedTools`.
---
## Security
@ -214,7 +236,7 @@ Replaced all native module calls with Python subprocess calls via `callPythonHel
| Limitation | Description |
|------------|-------------|
| macOS only | Windows/Linux need `pyobjc` replacements |
| Linux not adapted | Linux needs `wmctrl` + `xdotool` style platform integration |
| No global Escape abort | Original used CGEventTap; use `Ctrl+C` instead |
| No auto-hide windows | Original's `prepareDisplay` relied on Swift |
| Slightly higher latency | ~100ms Python process startup overhead per call |

View File

@ -29,6 +29,24 @@ Add to `~/.bashrc`:
export PATH="$HOME/path/to/claude-code-haha/bin:$PATH"
```
### Windows + WSL Toolchains
If `claude-haha` runs on Windows / Git Bash but tools such as Node, Python, uv, or bun are installed inside WSL, call them through WSL explicitly:
```bash
wsl -e bash -lc 'node --version && python3 --version'
```
When cc-haha detects `wsl` / `wsl.exe`, it automatically sets `MSYS2_ARG_CONV_EXCL=*` so Git Bash does not rewrite WSL paths such as `/home/...` into `C:/Program Files/Git/home/...`.
To route Bash tool commands through WSL by default, set this before startup:
```bash
export CLAUDE_CODE_SHELL_PREFIX='wsl -e bash -lc'
```
Computer Use still controls Windows desktop apps. CLI tools running inside WSL do not need to be added to `computer-use-config.json`. If you only need the WSL toolchain and do not need desktop control, disable Computer Use with `--no-computer-use` or the Settings > Computer Use switch.
## Verify
After setup, navigate to any project directory and test:

View File

@ -161,6 +161,25 @@ open "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapt
- 你在终端中确认允许哪些应用
- 之后模型就可以截图、点击、输入了
### 禁用 Computer Use
如果你只想使用普通 Coding Agent不希望暴露 `computer-use` MCP 工具,可以使用任一方式禁用:
```bash
claude-haha --no-computer-use
CLAUDE_COMPUTER_USE_ENABLED=0 claude-haha
```
也可以写入全局配置文件 `~/.claude/cc-haha/computer-use-config.json`
```json
{
"enabled": false
}
```
桌面端的 Settings > Computer Use 开关写入同一个配置。关闭后,新会话不会注入 `computer-use` 动态 MCP也不会把桌面控制工具加入 `allowedTools`
---
## 安全机制

View File

@ -29,6 +29,24 @@ source ~/.bashrc # 或 source ~/.zshrc
export PATH="$HOME/path/to/claude-code-haha/bin:$PATH"
```
### Windows + WSL 工具链
如果 `claude-haha` 运行在 Windows / Git Bash但 Node、Python、uv、bun 等工具主要安装在 WSL 里,可以显式通过 WSL 调用:
```bash
wsl -e bash -lc 'node --version && python3 --version'
```
cc-haha 会在检测到 `wsl` / `wsl.exe` 调用时自动设置 `MSYS2_ARG_CONV_EXCL=*`,避免 Git Bash 把 `/home/...` 这类 WSL 路径错误转换成 `C:/Program Files/Git/home/...`
如果你想让 Bash 工具默认进入 WSL可以在启动前设置
```bash
export CLAUDE_CODE_SHELL_PREFIX='wsl -e bash -lc'
```
Computer Use 仍然控制 Windows 桌面应用WSL 内的 CLI 工具不需要写入 `computer-use-config.json`。如果只使用 WSL 工具链、不需要桌面控制,建议使用 `--no-computer-use` 或在 Settings > Computer Use 中关闭它。
## 验证
配置完成后,进入任意项目目录测试:

View File

@ -1003,7 +1003,7 @@ async function run(): Promise<CommanderCommand> {
// `mcp` and `add` as paths, then choked on --transport as an unknown
// top-level option. Single-value + collect accumulator means each
// --plugin-dir takes exactly one arg; repeat the flag for multiple dirs.
.option('--plugin-dir <path>', 'Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B)', (val: string, prev: string[]) => [...prev, val], [] as string[]).option('--disable-slash-commands', 'Disable all skills', () => true).option('--chrome', 'Enable Claude in Chrome integration').option('--no-chrome', 'Disable Claude in Chrome integration').option('--file <specs...>', 'File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png)').action(async (prompt, options) => {
.option('--plugin-dir <path>', 'Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B)', (val: string, prev: string[]) => [...prev, val], [] as string[]).option('--disable-slash-commands', 'Disable all skills', () => true).option('--chrome', 'Enable Claude in Chrome integration').option('--no-chrome', 'Disable Claude in Chrome integration').option('--no-computer-use', 'Disable Computer Use MCP for this session').option('--file <specs...>', 'File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png)').action(async (prompt, options) => {
profileCheckpoint('action_handler_start');
// --bare = one-switch minimal mode. Sets SIMPLE so all the existing
@ -1610,19 +1610,30 @@ async function run(): Promise<CommanderCommand> {
const {
getChicagoEnabled
} = await import('src/utils/computerUse/gates.js');
if (getChicagoEnabled()) {
const computerUseCliEnabled = options.computerUse !== false;
if (getChicagoEnabled() && computerUseCliEnabled) {
const {
setupComputerUseMCP
} = await import('src/utils/computerUse/setup.js');
const {
mcpConfig,
allowedTools: cuTools
} = setupComputerUseMCP();
dynamicMcpConfig = {
...dynamicMcpConfig,
...mcpConfig
};
allowedTools.push(...cuTools);
loadStoredComputerUseConfig
} = await import('src/utils/computerUse/preauthorizedConfig.js');
const computerUseConfig = await loadStoredComputerUseConfig();
if (!computerUseConfig.enabled) {
logForDebugging('[Computer Use MCP] Skipped: disabled in computer-use-config.json');
} else {
const {
setupComputerUseMCP
} = await import('src/utils/computerUse/setup.js');
const {
mcpConfig,
allowedTools: cuTools
} = setupComputerUseMCP();
dynamicMcpConfig = {
...dynamicMcpConfig,
...mcpConfig
};
allowedTools.push(...cuTools);
}
} else if (!computerUseCliEnabled) {
logForDebugging('[Computer Use MCP] Skipped: disabled by --no-computer-use');
}
} catch (error) {
logForDebugging(`[Computer Use MCP] Setup failed: ${errorMessage(error)}`);

View File

@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { handleComputerUseApi } from '../api/computer-use.js'
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
let configDir: string | null = null
function makeRequest(method: string, body?: unknown): Request {
return new Request('http://localhost/api/computer-use/authorized-apps', {
method,
body: body === undefined ? undefined : JSON.stringify(body),
})
}
async function callAuthorizedApps(method: string, body?: unknown): Promise<Response> {
return handleComputerUseApi(
makeRequest(method, body),
new URL('http://localhost/api/computer-use/authorized-apps'),
['api', 'computer-use', 'authorized-apps'],
)
}
beforeEach(async () => {
configDir = await mkdtemp(join(tmpdir(), 'cc-haha-computer-use-api-'))
process.env.CLAUDE_CONFIG_DIR = configDir
})
afterEach(async () => {
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
if (configDir) {
await rm(configDir, { recursive: true, force: true })
configDir = null
}
})
describe('Computer Use API authorized app config', () => {
it('defaults Computer Use enabled for existing users without config', async () => {
const res = await callAuthorizedApps('GET')
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
enabled: true,
authorizedApps: [],
})
})
it('persists the Computer Use enabled flag independently', async () => {
const putRes = await callAuthorizedApps('PUT', { enabled: false })
expect(putRes.status).toBe(200)
const getRes = await callAuthorizedApps('GET')
expect(await getRes.json()).toMatchObject({ enabled: false })
const raw = await readFile(
join(configDir!, 'cc-haha', 'computer-use-config.json'),
'utf8',
)
expect(JSON.parse(raw)).toMatchObject({ enabled: false })
})
})

View File

@ -15,7 +15,11 @@ import { fileURLToPath } from 'url'
import type { CuPermissionRequest } from '../../vendor/computer-use-mcp/types.js'
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
import { detectPythonRuntime } from './computer-use-python.js'
import { DEFAULT_DESKTOP_GRANT_FLAGS } from '../../utils/computerUse/preauthorizedConfig.js'
import {
DEFAULT_DESKTOP_GRANT_FLAGS,
loadStoredComputerUseConfig,
saveStoredComputerUseConfig,
} from '../../utils/computerUse/preauthorizedConfig.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' }
@ -338,15 +342,14 @@ async function runSetup(): Promise<SetupResult> {
// Authorized Apps configuration — stored in ~/.claude/cc-haha/computer-use-config.json
// ============================================================================
const configPath = join(claudeHome, 'cc-haha', 'computer-use-config.json')
type AuthorizedApp = {
bundleId: string
displayName: string
authorizedAt: string
authorizedAt?: string
}
type ComputerUseConfig = {
enabled: boolean
authorizedApps: AuthorizedApp[]
grantFlags: {
clipboardRead: boolean
@ -361,21 +364,17 @@ type RequestAccessBody = {
}
const DEFAULT_CONFIG: ComputerUseConfig = {
enabled: true,
authorizedApps: [],
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
}
async function loadConfig(): Promise<ComputerUseConfig> {
try {
const raw = await readFile(configPath, 'utf8')
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) }
} catch {
return { ...DEFAULT_CONFIG }
}
return { ...DEFAULT_CONFIG, ...(await loadStoredComputerUseConfig()) }
}
async function saveConfig(config: ComputerUseConfig): Promise<void> {
await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8')
await saveStoredComputerUseConfig(config)
}
async function listInstalledApps(): Promise<{ bundleId: string; displayName: string; path: string }[]> {
@ -437,6 +436,7 @@ export async function handleComputerUseApi(
try {
const body = (await req.json()) as Partial<ComputerUseConfig>
const config = await loadConfig()
if (body.enabled !== undefined) config.enabled = body.enabled
if (body.authorizedApps) config.authorizedApps = body.authorizedApps
if (body.grantFlags) config.grantFlags = { ...config.grantFlags, ...body.grantFlags }
await saveConfig(config)

View File

@ -0,0 +1,27 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { getChicagoEnabled } from './gates.js'
const ORIGINAL_ENABLED = process.env.CLAUDE_COMPUTER_USE_ENABLED
afterEach(() => {
if (ORIGINAL_ENABLED === undefined) {
delete process.env.CLAUDE_COMPUTER_USE_ENABLED
} else {
process.env.CLAUDE_COMPUTER_USE_ENABLED = ORIGINAL_ENABLED
}
})
describe('getChicagoEnabled', () => {
test('defaults Computer Use on', () => {
delete process.env.CLAUDE_COMPUTER_USE_ENABLED
expect(getChicagoEnabled()).toBe(true)
})
test('honors explicit falsy env values', () => {
process.env.CLAUDE_COMPUTER_USE_ENABLED = '0'
expect(getChicagoEnabled()).toBe(false)
process.env.CLAUDE_COMPUTER_USE_ENABLED = 'false'
expect(getChicagoEnabled()).toBe(false)
})
})

View File

@ -1,6 +1,7 @@
import type { CoordinateMode, CuSubGates } from '../../vendor/computer-use-mcp/types.js'
import { getDynamicConfig_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { isEnvDefinedFalsy } from '../envUtils.js'
type ChicagoConfig = CuSubGates & {
enabled: boolean
@ -32,6 +33,10 @@ function readConfig(): ChicagoConfig {
}
export function getChicagoEnabled(): boolean {
if (isEnvDefinedFalsy(process.env.CLAUDE_COMPUTER_USE_ENABLED)) {
return false
}
return true
}

View File

@ -7,11 +7,19 @@ import {
describe('resolveStoredComputerUseConfig', () => {
test('keeps desktop grant flags enabled by default even without authorized apps', () => {
expect(resolveStoredComputerUseConfig()).toEqual({
enabled: true,
authorizedApps: [],
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
})
})
test('preserves an explicit disabled state', () => {
expect(resolveStoredComputerUseConfig({ enabled: false })).toMatchObject({
enabled: false,
authorizedApps: [],
})
})
test('merges stored grant flags without discarding unspecified defaults', () => {
expect(
resolveStoredComputerUseConfig({
@ -20,6 +28,7 @@ describe('resolveStoredComputerUseConfig', () => {
},
}),
).toEqual({
enabled: true,
authorizedApps: [],
grantFlags: {
clipboardRead: false,
@ -29,4 +38,3 @@ describe('resolveStoredComputerUseConfig', () => {
})
})
})

View File

@ -1,28 +1,45 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { CuGrantFlags } from '../../vendor/computer-use-mcp/types.js'
import { getClaudeConfigHomeDir } from '../envUtils.js'
export type StoredAuthorizedApp = {
bundleId: string
displayName: string
authorizedAt?: string
}
export type StoredComputerUseConfig = {
enabled?: boolean
authorizedApps?: StoredAuthorizedApp[]
grantFlags?: Partial<CuGrantFlags>
}
export const DEFAULT_COMPUTER_USE_ENABLED = true
export const DEFAULT_DESKTOP_GRANT_FLAGS: CuGrantFlags = {
clipboardRead: true,
clipboardWrite: true,
systemKeyCombos: true,
}
export function getComputerUseConfigPath(): string {
return join(
getClaudeConfigHomeDir(),
'cc-haha',
'computer-use-config.json',
)
}
export function resolveStoredComputerUseConfig(
config?: StoredComputerUseConfig,
): {
enabled: boolean
authorizedApps: StoredAuthorizedApp[]
grantFlags: CuGrantFlags
} {
return {
enabled: config?.enabled ?? DEFAULT_COMPUTER_USE_ENABLED,
authorizedApps: config?.authorizedApps ?? [],
grantFlags: {
...DEFAULT_DESKTOP_GRANT_FLAGS,
@ -31,3 +48,21 @@ export function resolveStoredComputerUseConfig(
}
}
export async function loadStoredComputerUseConfig(): Promise<
ReturnType<typeof resolveStoredComputerUseConfig>
> {
try {
const raw = await readFile(getComputerUseConfigPath(), 'utf8')
return resolveStoredComputerUseConfig(JSON.parse(raw))
} catch {
return resolveStoredComputerUseConfig()
}
}
export async function saveStoredComputerUseConfig(
config: StoredComputerUseConfig,
): Promise<void> {
const configPath = getComputerUseConfigPath()
await mkdir(dirname(configPath), { recursive: true })
await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8')
}

View File

@ -27,10 +27,7 @@ import { registerEscHotkey } from './escHotkey.js';
import { getChicagoCoordinateMode } from './gates.js';
import { getComputerUseHostAdapter } from './hostAdapter.js';
import { getComputerUseMCPRenderingOverrides } from './toolRendering.js';
import { resolveStoredComputerUseConfig } from './preauthorizedConfig.js';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { loadStoredComputerUseConfig } from './preauthorizedConfig.js';
type CallOverride = Pick<Tool, 'call'>['call'];
type Binding = {
ctx: ComputerUseSessionContext;
@ -270,30 +267,11 @@ async function runDesktopPermissionDialog(
* immediately no runtime permission dialog needed.
*/
async function loadPreAuthorizedApps(): Promise<void> {
let config:
| {
authorizedApps?: { bundleId: string; displayName: string }[]
grantFlags?: { clipboardRead?: boolean; clipboardWrite?: boolean; systemKeyCombos?: boolean }
}
| undefined
try {
const configPath = join(
process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'),
'cc-haha',
'computer-use-config.json',
)
const raw = await readFile(configPath, 'utf8')
config = JSON.parse(raw) as typeof config
} catch {
// Config doesn't exist yet — still honor desktop defaults for grant flags.
}
if (!currentToolUseContext) {
return
}
const resolved = resolveStoredComputerUseConfig(config)
const resolved = await loadStoredComputerUseConfig()
const apps = resolved.authorizedApps.map(a => ({
bundleId: a.bundleId,
displayName: a.displayName,

View File

@ -23,6 +23,7 @@ import {
} from '../tmuxSocket.js'
import { windowsPathToPosixPath } from '../windowsPaths.js'
import { resolveClaudeCliLauncher } from '../desktopBundledCli.js'
import { getWslInteropEnvironmentOverrides } from './wslInterop.js'
import type { ShellProvider } from './shellProvider.js'
/**
@ -279,6 +280,14 @@ export async function createBashShellProvider(
// Safe to set unconditionally — non-zsh shells ignore TMPPREFIX.
env.TMPPREFIX = posixJoin(posixTmpDir, 'zsh')
}
Object.assign(
env,
getWslInteropEnvironmentOverrides({
platform: getPlatform(),
command,
shellPrefix: process.env.CLAUDE_CODE_SHELL_PREFIX,
}),
)
// Apply session env vars set via /env (child processes only, not the REPL)
for (const [key, value] of getSessionEnvVars()) {
env[key] = value

View File

@ -0,0 +1,50 @@
import { describe, expect, test } from 'bun:test'
import {
commandInvokesWsl,
getWslInteropEnvironmentOverrides,
} from './wslInterop.js'
describe('wsl interop environment', () => {
test('detects direct wsl invocations', () => {
expect(commandInvokesWsl('wsl ls /home/lenovo')).toBe(true)
expect(commandInvokesWsl('echo ok && wsl.exe -e bash -lc pwd')).toBe(true)
expect(commandInvokesWsl('echo browser-wsl-helper')).toBe(false)
})
test('disables MSYS path conversion for Windows wsl commands', () => {
expect(
getWslInteropEnvironmentOverrides({
platform: 'windows',
command: 'wsl ls /home/lenovo',
currentEnv: {},
}),
).toEqual({
MSYS2_ARG_CONV_EXCL: '*',
WSL_UTF8: '1',
})
})
test('also handles shell prefixes that route commands through WSL', () => {
expect(
getWslInteropEnvironmentOverrides({
platform: 'windows',
command: 'node --version',
shellPrefix: 'wsl -e bash -lc',
currentEnv: { MSYS2_ARG_CONV_EXCL: '/mnt/*' },
}),
).toEqual({
MSYS2_ARG_CONV_EXCL: '/mnt/*',
WSL_UTF8: '1',
})
})
test('does nothing away from Windows', () => {
expect(
getWslInteropEnvironmentOverrides({
platform: 'wsl',
command: 'wsl ls /home/lenovo',
currentEnv: {},
}),
).toEqual({})
})
})

View File

@ -0,0 +1,27 @@
import type { Platform } from '../platform.js'
const WSL_COMMAND_PATTERN = /(^|[\s;&|()<>'"])wsl(?:\.exe)?(?=$|[\s;&|()<>'"])/
export function commandInvokesWsl(command?: string): boolean {
return WSL_COMMAND_PATTERN.test(command ?? '')
}
export function getWslInteropEnvironmentOverrides({
platform,
command,
shellPrefix,
currentEnv = process.env,
}: {
platform: Platform
command: string
shellPrefix?: string
currentEnv?: NodeJS.ProcessEnv
}): Record<string, string> {
if (platform !== 'windows') return {}
if (!commandInvokesWsl(command) && !commandInvokesWsl(shellPrefix)) return {}
return {
MSYS2_ARG_CONV_EXCL: currentEnv.MSYS2_ARG_CONV_EXCL ?? '*',
WSL_UTF8: currentEnv.WSL_UTF8 ?? '1',
}
}