mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge commit '1cd90dc'
This commit is contained in:
commit
c0fe61b665
@ -46,6 +46,7 @@ export type AuthorizedApp = {
|
||||
}
|
||||
|
||||
export type ComputerUseConfig = {
|
||||
enabled: boolean
|
||||
authorizedApps: AuthorizedApp[]
|
||||
grantFlags: {
|
||||
clipboardRead: boolean
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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 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。',
|
||||
|
||||
98
desktop/src/pages/ComputerUseSettings.test.tsx
Normal file
98
desktop/src/pages/ComputerUseSettings.test.tsx
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')}
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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`。
|
||||
|
||||
---
|
||||
|
||||
## 安全机制
|
||||
|
||||
@ -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 中关闭它。
|
||||
|
||||
## 验证
|
||||
|
||||
配置完成后,进入任意项目目录测试:
|
||||
|
||||
37
src/main.tsx
37
src/main.tsx
@ -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)}`);
|
||||
|
||||
68
src/server/__tests__/computer-use-api.test.ts
Normal file
68
src/server/__tests__/computer-use-api.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
27
src/utils/computerUse/gates.test.ts
Normal file
27
src/utils/computerUse/gates.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
50
src/utils/shell/wslInterop.test.ts
Normal file
50
src/utils/shell/wslInterop.test.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
27
src/utils/shell/wslInterop.ts
Normal file
27
src/utils/shell/wslInterop.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user