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', + } +}