feat: let users opt out of bundled Computer Use

Computer Use is useful when explicitly needed, but exposing its MCP tools by default creates unnecessary desktop-control surface for users who want coding-only sessions. This adds a shared disable path for CLI flags, environment, and desktop settings while keeping preauthorized app state in one config file.

The same change also preserves Windows and WSL shell startup behavior by applying the MSYS argument-conversion guard only on WSL-bound launches.

Constraint: Computer Use MCP must not be exposed to the Coding Agent when disabled

Constraint: Desktop settings and CLI sessions need to read the same persisted Computer Use config

Rejected: Environment-only disable switch | desktop users need a persistent Settings control

Rejected: Remove Computer Use setup entirely | enabled sessions still need the existing built-in MCP path

Confidence: high

Scope-risk: moderate

Directive: Keep every new Computer Use entrypoint wired through loadStoredComputerUseConfig or the CLI disable flag before adding MCP tools

Tested: bun test src/utils/computerUse/gates.test.ts src/utils/computerUse/preauthorizedConfig.test.ts src/server/__tests__/computer-use-api.test.ts src/utils/shell/wslInterop.test.ts desktop/src/pages/ComputerUseSettings.test.tsx

Tested: bun run check:server; bun run check:desktop; bun run check:docs; bun run check:policy; bun run check:native; git diff --check

Tested: SKIP_INSTALL=1 ./desktop/scripts/build-macos-arm64.sh; codesign verify; hdiutil verify; built CLI Computer Use E2E exposure and disable checks

Not-tested: Full screenshot/control action after granting macOS Screen Recording permission on this machine
This commit is contained in:
程序员阿江(Relakkes) 2026-05-06 11:40:49 +08:00
parent 4e9c6dbda1
commit 1cd90dc66a
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',
}
}