From 1cd90dc66a4446810b162bbf474760bb91dea862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 6 May 2026 11:40:49 +0800 Subject: [PATCH] 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 --- desktop/src/api/computerUse.ts | 1 + desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + .../src/pages/ComputerUseSettings.test.tsx | 98 +++++++++++++++++++ desktop/src/pages/ComputerUseSettings.tsx | 58 +++++++++-- docs/en/features/computer-use.md | 32 +++++- docs/en/guide/global-usage.md | 18 ++++ docs/features/computer-use.md | 19 ++++ docs/guide/global-usage.md | 18 ++++ src/main.tsx | 37 ++++--- src/server/__tests__/computer-use-api.test.ts | 68 +++++++++++++ src/server/api/computer-use.ts | 22 ++--- src/utils/computerUse/gates.test.ts | 27 +++++ src/utils/computerUse/gates.ts | 5 + .../computerUse/preauthorizedConfig.test.ts | 10 +- src/utils/computerUse/preauthorizedConfig.ts | 35 +++++++ src/utils/computerUse/wrapper.tsx | 26 +---- src/utils/shell/bashProvider.ts | 9 ++ src/utils/shell/wslInterop.test.ts | 50 ++++++++++ src/utils/shell/wslInterop.ts | 27 +++++ 20 files changed, 501 insertions(+), 63 deletions(-) create mode 100644 desktop/src/pages/ComputerUseSettings.test.tsx create mode 100644 src/server/__tests__/computer-use-api.test.ts create mode 100644 src/utils/computerUse/gates.test.ts create mode 100644 src/utils/shell/wslInterop.test.ts create mode 100644 src/utils/shell/wslInterop.ts diff --git a/desktop/src/api/computerUse.ts b/desktop/src/api/computerUse.ts index bcd36501..f0c50e27 100644 --- a/desktop/src/api/computerUse.ts +++ b/desktop/src/api/computerUse.ts @@ -46,6 +46,7 @@ export type AuthorizedApp = { } export type ComputerUseConfig = { + enabled: boolean authorizedApps: AuthorizedApp[] grantFlags: { clipboardRead: boolean diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 64fbe7cb..fb27d9d6 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 54121d8c..d6e67a19 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -576,6 +576,8 @@ export const zh: Record = { 'settings.tab.computerUse': 'Computer Use', 'settings.computerUse.title': 'Computer Use', 'settings.computerUse.description': '允许 Claude 截屏、点击、打字并控制你的电脑。需要 Python 3,macOS 上还需要辅助功能权限。', + '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。', diff --git a/desktop/src/pages/ComputerUseSettings.test.tsx b/desktop/src/pages/ComputerUseSettings.test.tsx new file mode 100644 index 00000000..a31b720f --- /dev/null +++ b/desktop/src/pages/ComputerUseSettings.test.tsx @@ -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() + + 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() + + 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, + }) + }) +}) diff --git a/desktop/src/pages/ComputerUseSettings.tsx b/desktop/src/pages/ComputerUseSettings.tsx index beb26552..4e81efc4 100644 --- a/desktop/src/pages/ComputerUseSettings.tsx +++ b/desktop/src/pages/ComputerUseSettings.tsx @@ -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>) => { + 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() {
{/* Title */}
-

- {t('settings.computerUse.title')} -

+
+

+ {t('settings.computerUse.title')} +

+ +

{t('settings.computerUse.description')}

+ {!computerUseEnabled && ( +
+ {t('settings.computerUse.disabledHint')} +
+ )} + {checkState === 'loading' ? (
{t('common.loading')} diff --git a/docs/en/features/computer-use.md b/docs/en/features/computer-use.md index 30a51933..4903b127 100644 --- a/docs/en/features/computer-use.md +++ b/docs/en/features/computer-use.md @@ -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 | diff --git a/docs/en/guide/global-usage.md b/docs/en/guide/global-usage.md index 828b0fba..061f4c7c 100644 --- a/docs/en/guide/global-usage.md +++ b/docs/en/guide/global-usage.md @@ -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: diff --git a/docs/features/computer-use.md b/docs/features/computer-use.md index 23bc682b..8bd3cdaa 100644 --- a/docs/features/computer-use.md +++ b/docs/features/computer-use.md @@ -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`。 + --- ## 安全机制 diff --git a/docs/guide/global-usage.md b/docs/guide/global-usage.md index 4a5f87f3..21d76abb 100644 --- a/docs/guide/global-usage.md +++ b/docs/guide/global-usage.md @@ -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 中关闭它。 + ## 验证 配置完成后,进入任意项目目录测试: diff --git a/src/main.tsx b/src/main.tsx index b57451d7..29f23b3f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1003,7 +1003,7 @@ async function run(): Promise { // `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 ', '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 ', '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 ', '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 ', '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 { 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)}`); diff --git a/src/server/__tests__/computer-use-api.test.ts b/src/server/__tests__/computer-use-api.test.ts new file mode 100644 index 00000000..12bbb5c6 --- /dev/null +++ b/src/server/__tests__/computer-use-api.test.ts @@ -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 { + 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 }) + }) +}) diff --git a/src/server/api/computer-use.ts b/src/server/api/computer-use.ts index 16239161..f2b31111 100644 --- a/src/server/api/computer-use.ts +++ b/src/server/api/computer-use.ts @@ -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 { // 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 { - 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 { - 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 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) diff --git a/src/utils/computerUse/gates.test.ts b/src/utils/computerUse/gates.test.ts new file mode 100644 index 00000000..4bdb9895 --- /dev/null +++ b/src/utils/computerUse/gates.test.ts @@ -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) + }) +}) diff --git a/src/utils/computerUse/gates.ts b/src/utils/computerUse/gates.ts index c94603d1..b2c53573 100644 --- a/src/utils/computerUse/gates.ts +++ b/src/utils/computerUse/gates.ts @@ -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 } diff --git a/src/utils/computerUse/preauthorizedConfig.test.ts b/src/utils/computerUse/preauthorizedConfig.test.ts index 4d93f1d0..6a3e6cf4 100644 --- a/src/utils/computerUse/preauthorizedConfig.test.ts +++ b/src/utils/computerUse/preauthorizedConfig.test.ts @@ -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', () => { }) }) }) - diff --git a/src/utils/computerUse/preauthorizedConfig.ts b/src/utils/computerUse/preauthorizedConfig.ts index 87495e2b..884ca054 100644 --- a/src/utils/computerUse/preauthorizedConfig.ts +++ b/src/utils/computerUse/preauthorizedConfig.ts @@ -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 } +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 +> { + try { + const raw = await readFile(getComputerUseConfigPath(), 'utf8') + return resolveStoredComputerUseConfig(JSON.parse(raw)) + } catch { + return resolveStoredComputerUseConfig() + } +} + +export async function saveStoredComputerUseConfig( + config: StoredComputerUseConfig, +): Promise { + const configPath = getComputerUseConfigPath() + await mkdir(dirname(configPath), { recursive: true }) + await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8') +} diff --git a/src/utils/computerUse/wrapper.tsx b/src/utils/computerUse/wrapper.tsx index 0a656d61..7c65eb9a 100644 --- a/src/utils/computerUse/wrapper.tsx +++ b/src/utils/computerUse/wrapper.tsx @@ -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['call']; type Binding = { ctx: ComputerUseSessionContext; @@ -270,30 +267,11 @@ async function runDesktopPermissionDialog( * immediately — no runtime permission dialog needed. */ async function loadPreAuthorizedApps(): Promise { - 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, diff --git a/src/utils/shell/bashProvider.ts b/src/utils/shell/bashProvider.ts index 4db50e69..779a8cef 100644 --- a/src/utils/shell/bashProvider.ts +++ b/src/utils/shell/bashProvider.ts @@ -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 diff --git a/src/utils/shell/wslInterop.test.ts b/src/utils/shell/wslInterop.test.ts new file mode 100644 index 00000000..1ffd3b80 --- /dev/null +++ b/src/utils/shell/wslInterop.test.ts @@ -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({}) + }) +}) diff --git a/src/utils/shell/wslInterop.ts b/src/utils/shell/wslInterop.ts new file mode 100644 index 00000000..e032e389 --- /dev/null +++ b/src/utils/shell/wslInterop.ts @@ -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 { + 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', + } +}