/** * Computer Use API — 环境检测与依赖安装 * * Routes: * GET /api/computer-use/status — 检测 Python3、venv、依赖、权限状态 * POST /api/computer-use/setup — 创建 venv 并安装依赖 */ import { homedir } from 'os' import { join } from 'path' import { access, readFile, mkdir, writeFile, rm } from 'fs/promises' import { createHash } from 'crypto' import path from 'path' import { fileURLToPath } from 'url' import type { AppGrant, CuPermissionRequest } from '../../vendor/computer-use-mcp/types.js' import { computerUseApprovalService } from '../services/computerUseApprovalService.js' import { diagnosticsService } from '../services/diagnosticsService.js' import { normalizeIconSize, readAppIconPng } from '../services/macAppIcon.js' import { listInstalledMacApps } from './macInstalledApps.js' import { detectPythonRuntime, isPythonVersionAtLeast } from './computer-use-python.js' import { buildPipInstallAttempts } from '../../utils/computerUse/pipInstall.js' import { DEFAULT_DESKTOP_GRANT_FLAGS, getComputerUseConfigPath, loadStoredComputerUseConfig, loadStoredComputerUseConfigResult, normalizePythonPath, saveStoredComputerUseConfig, } from '../../utils/computerUse/preauthorizedConfig.js' import { callCuHelper, isMacosComputerUseRuntimeSupported, resolveLaunchableCuHelperBinary, } from '../../utils/computerUse/cuHelperBridge.js' // Embed the runtime scripts at compile time so bundled mode has them without // shipping loose files. Windows only: macOS drives Computer Use through the // signed native `cu-helper` daemon, and `helperBridge` refuses to fall back to // Python there, so a macOS helper script would be dead weight in the binary. // @ts-ignore — Bun text import import WIN_HELPER_CONTENT from '../../../runtime/win_helper.py' with { type: 'text' } // @ts-ignore — Bun text import import WIN_CURSOR_BADGE_CONTENT from '../../../runtime/win_cursor_badge.py' with { type: 'text' } // @ts-ignore — Bun text import import REQUIREMENTS_WIN32 from '../../../runtime/requirements-win.txt' with { type: 'text' } const __dirname = path.dirname(fileURLToPath(import.meta.url)) const projectRoot = path.resolve(__dirname, '../../..') const devRuntimeRoot = join(projectRoot, 'runtime') const claudeHome = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude') const runtimeStateRoot = join(claudeHome, '.runtime') const venvRoot = join(runtimeStateRoot, 'venv') const installStampPath = join(runtimeStateRoot, 'requirements.sha256') // 记录上次创建 venv 时所用的 config.pythonPath 原值。读取该文件来判断当前 // venv 是否仍与最新的自定义路径配置一致。 const baseInterpreterMarkerPath = join(runtimeStateRoot, 'venv-base-interpreter.txt') const MIN_PYTHON_MAJOR = 3 const MIN_PYTHON_MINOR = 9 export const MIN_MACOS_COMPUTER_USE_VERSION = '14.4' const isWindows = process.platform === 'win32' const REQUIREMENTS_CONTENT = REQUIREMENTS_WIN32 function getPythonCommandEnv(): Record | undefined { if (!isWindows) return undefined return { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1', } as Record } // Paths that resolve correctly in both dev and bundled modes function getRequirementsPath(): string { return join(runtimeStateRoot, 'requirements.txt') } function getHelperFileName(): string { return 'win_helper.py' } /** The agent-activity badge runs as its own process, so it ships separately. */ function getCursorBadgePath(): string { return join(runtimeStateRoot, 'win_cursor_badge.py') } function getHelperPath(): string { return join(runtimeStateRoot, getHelperFileName()) } async function pathExists(target: string): Promise { try { await access(target) return true } catch { return false } } /** * 判断现有 venv 是否与当前 config.pythonPath 配置一致。 * * 通过 marker 文件记录上次 setup 时所用的解释器路径——这比解析 pyvenv.cfg * 可靠得多:当用户自定义 Python 自身在一个 venv 里时(conda/pyenv/手工 venv * 都是这种情况),Python 的 venv 模块会跳过外层 venv 直接指向 base 解释器, * 导致 pyvenv.cfg 的 home 字段记录的是 base 而非用户提供的那个路径。 * * 兼容性:marker 缺失时—— * - 若用户也没设置自定义路径(current === ''),视为老用户的合法 venv, * 返回 true 不打扰。 * - 若用户设置了自定义路径(current !== ''),说明这是 marker 引入前建立 * 的旧 venv,绝非由当前自定义路径建立,返回 false 触发重建。 */ async function venvBaseInterpreterMatches( currentCustomPath: string | null | undefined, ): Promise { const current = (currentCustomPath ?? '').trim() try { const recorded = (await readFile(baseInterpreterMarkerPath, 'utf8')).trim() return recorded === current } catch { return current === '' } } async function runCommand( cmd: string, args: string[], ): Promise<{ ok: boolean; stdout: string; stderr: string; code: number }> { try { const proc = Bun.spawn([cmd, ...args], { stdout: 'pipe', stderr: 'pipe', env: getPythonCommandEnv(), }) const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]) const code = await proc.exited return { ok: code === 0, stdout: stdout.trim(), stderr: stderr.trim(), code } } catch { return { ok: false, stdout: '', stderr: `Failed to run ${cmd}`, code: -1 } } } export async function runPipInstallWithFallback( pythonCmd: string, baseArgs: string[], run: typeof runCommand = runCommand, ): Promise<{ ok: boolean; stdout: string; stderr: string; code: number }> { let firstFailure: { ok: boolean; stdout: string; stderr: string; code: number } | null = null for (const args of buildPipInstallAttempts(baseArgs)) { const result = await run(pythonCmd, args) if (result.ok) return result firstFailure ??= result } return firstFailure ?? { ok: false, stdout: '', stderr: 'pip install failed', code: -1 } } /** * Ensure the Windows runtime files exist in ~/.claude/.runtime/. * * All three are written from constants embedded at compile time, so dev and * bundled mode behave identically and a stale copy from an earlier version is * always overwritten rather than left in place. * * Windows only, in the same sense as the imports above: macOS never reaches * the Python path. */ async function ensureRuntimeFiles(): Promise { await mkdir(runtimeStateRoot, { recursive: true }) await writeFile(getRequirementsPath(), REQUIREMENTS_CONTENT, 'utf8') await writeFile(getHelperPath(), WIN_HELPER_CONTENT, 'utf8') // Ships alongside the helper because the helper is a stateless one-shot CLI // and cannot own a window across actions; the badge needs its own process. await writeFile(getCursorBadgePath(), WIN_CURSOR_BADGE_CONTENT, 'utf8') } type EnvStatus = { platform: string supported: boolean engine: 'macos-native' | 'windows-compat' | 'unsupported' systemVersion: string | null arch: string /** * Native cu-helper engine availability. `available` is true only on macOS * AND when the Swift `cu-helper` binary resolves. The desktop UI branches on * this to drop the Python setup flow in favor of the native permission card. */ cuHelper: { available: boolean supported: boolean minimumMacosVersion: typeof MIN_MACOS_COMPUTER_USE_VERSION reason: | 'unsupported_platform' | 'system_version_unknown' | 'os_too_old' | 'helper_missing' | null } python: { installed: boolean version: string | null path: string | null source: 'custom' | 'system' | 'venv' | null error: string | null } venv: { created: boolean path: string } dependencies: { installed: boolean requirementsFound: boolean } permissions: { accessibility: boolean | null screenRecording: boolean | null error: string | null } } type ComputerUseCapability = Pick & { cuHelper: EnvStatus['cuHelper'] } export function isVersionAtLeast(version: string, minimum: string): boolean { const parse = (value: string) => value.split('.').map((part) => { const parsed = Number.parseInt(part, 10) return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 }) const actual = parse(version) const floor = parse(minimum) const length = Math.max(actual.length, floor.length) for (let index = 0; index < length; index += 1) { const left = actual[index] ?? 0 const right = floor[index] ?? 0 if (left !== right) return left > right } return true } /** * Choose the implementation from platform/OS eligibility first. A missing * native helper is a runtime failure on the native path, never a reason to * fall back to the obsolete macOS Python page. */ export function resolveComputerUseCapability( platform: string, systemVersion: string | null, helperPresent: boolean, kernelVersionEligible = false, ): ComputerUseCapability { const baseHelper = { minimumMacosVersion: MIN_MACOS_COMPUTER_USE_VERSION, } as const if (platform === 'win32') { return { supported: true, engine: 'windows-compat', cuHelper: { ...baseHelper, available: false, supported: false, reason: 'unsupported_platform', }, } } if (platform !== 'darwin') { return { supported: false, engine: 'unsupported', cuHelper: { ...baseHelper, available: false, supported: false, reason: 'unsupported_platform', }, } } if (!systemVersion && !kernelVersionEligible) { return { supported: false, engine: 'unsupported', cuHelper: { ...baseHelper, available: false, supported: false, reason: 'system_version_unknown', }, } } if (systemVersion && !isVersionAtLeast(systemVersion, MIN_MACOS_COMPUTER_USE_VERSION)) { return { supported: false, engine: 'unsupported', cuHelper: { ...baseHelper, available: false, supported: false, reason: 'os_too_old', }, } } return { supported: true, engine: 'macos-native', cuHelper: { ...baseHelper, available: helperPresent, supported: true, reason: helperPresent ? null : 'helper_missing', }, } } /** * macOS permission snapshot result shape from `cu-helper check_permissions` * (Permissions.snapshot()). Booleans only — `screenRecording` may be reported * as a boolean by the snapshot; we coerce anything missing to null. */ type CuHelperPermissions = { accessibility?: boolean screenRecording?: boolean } /** * True only on macOS AND when the native `cu-helper` can be launched from its * canonical installation. This also verifies/install-repairs the packaged * helper instead of reporting a nested app-bundle binary as healthy. */ function isCuHelperAvailableForServer(): boolean { return process.platform === 'darwin' && resolveLaunchableCuHelperBinary() !== null } /** * Read macOS Accessibility / Screen Recording status via the native engine, * with NO Python prerequisite. Returns nulls on any failure so the caller can * surface "unknown" instead of throwing. */ export async function checkCuHelperPermissions( // Injected the same way `callCuHelper` injects its own exec, so the failure // branch below is reachable from a test without a real helper binary. call: typeof callCuHelper = callCuHelper, ): Promise<{ accessibility: boolean | null screenRecording: boolean | null error: string | null }> { try { const result = await call('check_permissions') return { accessibility: result.accessibility ?? null, screenRecording: result.screenRecording ?? null, error: null, } } catch (error) { // Nulls reach the settings page as a permanent "checking…" — the UI cannot // tell "not probed yet" from "probe failed". Swallowing the reason silently // once cost a long investigation to rediscover that the shipped sidecar had // been re-signed and the helper was answering `unauthorized_client`. // // This is an error, not a warning: the caller only gets here when the helper // binary IS present, so the user did nothing wrong and the check still did // not complete. A helper that is merely un-granted answers with `false`. void diagnosticsService.recordEvent({ type: 'computer_use_permission_probe_failed', severity: 'error', summary: error instanceof Error ? error.message : 'cu-helper check_permissions failed', details: { command: 'check_permissions', // `unauthorized_client` means the process chain failed attestation — // usually a signing-identity mismatch somewhere in helper -> sidecar // -> desktop, not a missing OS grant. hint: 'permissions stay unknown until this call succeeds', }, }) return { accessibility: null, screenRecording: null, error: error instanceof Error ? error.message : 'cu-helper check_permissions failed', } } } /** * List installed macOS apps via the native engine (no Python prerequisite). * Mirrors the Python list_installed_apps shape but drops any icon field so the * picker payload stays small. Returns [] on failure. */ async function listInstalledAppsViaCuHelper(): Promise< { bundleId: string; displayName: string; path: string }[] > { try { const apps = await callCuHelper< { bundleId: string; displayName: string; path: string }[] >('list_installed_apps') if (!Array.isArray(apps)) return [] return apps.map((app) => ({ bundleId: app.bundleId, displayName: app.displayName, path: app.path, })) } catch { return [] } } /** * Spawn the native permission card (`cu-helper request-access`) and await the * single stdout snapshot line the card prints when the user closes it * (PermissionCard.swift windowWillClose -> `{ok:true,result:{accessibility, * screenRecording}}`). Unlike the fire-and-forget nativePermissionCard.ts in * the CLI process, the settings page WANTS the post-close snapshot, so we read * stdout here. Resolves only when the window closes (hence the caller uses a * long client timeout). Returns nulls off macOS / when the binary is missing. */ async function openNativePermissionCard(): Promise<{ ok: boolean reason?: string accessibility: boolean | null screenRecording: boolean | null }> { if (process.platform !== 'darwin') { return { ok: false, reason: 'unsupported', accessibility: null, screenRecording: null } } // Launch from the standalone-installed helper so the card drags the same `.app` // the daemon runs from (its own Screen Recording subject). See cuHelperInstall.ts. const bin = resolveLaunchableCuHelperBinary() if (!bin) { return { ok: false, reason: 'helper-missing', accessibility: null, screenRecording: null } } // `request-access` is its OWN process mode (not a `--payload` CLI command): // it owns an NSApplication run loop and blocks until the card window closes, // then prints exactly one `{ok,result}` line. runCommand spawns + reads // stdout + awaits exit, which is exactly what we need. const result = await runCommand(bin, ['request-access']) return resolvePermissionCardCommandResult(result) } export function resolvePermissionCardCommandResult(result: { ok: boolean stdout: string stderr: string code: number }): { ok: boolean reason?: string accessibility: boolean | null screenRecording: boolean | null } { if (!result.ok) { return { ok: false, reason: result.stderr || `permission card exited with code ${result.code}`, accessibility: null, screenRecording: null, } } // Parse the final snapshot line. The card always exits 0; tolerate trailing // log chatter by scanning lines for the last valid JSON envelope. const perms = parsePermissionSnapshot(result.stdout) if (perms.accessibility === null && perms.screenRecording === null) { return { ok: false, reason: 'permission card returned no permission snapshot', accessibility: null, screenRecording: null, } } return { ok: true, accessibility: perms.accessibility, screenRecording: perms.screenRecording } } /** * Extract `{accessibility, screenRecording}` from the cu-helper card's stdout. * Scans from the LAST line backwards for a parseable `{ok,result}` envelope so * any incidental earlier output is ignored. Returns nulls when none is found. */ export function parsePermissionSnapshot(stdout: string): { accessibility: boolean | null screenRecording: boolean | null } { const lines = stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) for (let i = lines.length - 1; i >= 0; i--) { try { const parsed = JSON.parse(lines[i]) as { ok?: boolean result?: CuHelperPermissions } if (parsed.ok && parsed.result) { return { accessibility: parsed.result.accessibility ?? null, screenRecording: parsed.result.screenRecording ?? null, } } } catch { // not JSON — keep scanning earlier lines } } return { accessibility: null, screenRecording: null } } async function detectMacosProductVersion(): Promise { if (process.platform !== 'darwin') return null const result = await runCommand('/usr/bin/sw_vers', ['-productVersion']) return result.ok && result.stdout ? result.stdout : null } type CheckStatusDependencies = { platform?: string arch?: string detectMacosProductVersion?: () => Promise isMacosRuntimeSupported?: (platform: string) => boolean isCuHelperAvailable?: () => boolean checkPermissions?: typeof checkCuHelperPermissions } export async function checkStatus( dependencies: CheckStatusDependencies = {}, ): Promise { const platform = dependencies.platform ?? process.platform const arch = dependencies.arch ?? process.arch const systemVersion = platform === 'darwin' ? await (dependencies.detectMacosProductVersion ?? detectMacosProductVersion)() : null const kernelVersionEligible = platform === 'darwin' && (dependencies.isMacosRuntimeSupported ?? isMacosComputerUseRuntimeSupported)(platform) const macosEligible = platform === 'darwin' && (systemVersion !== null ? isVersionAtLeast(systemVersion, MIN_MACOS_COMPUTER_USE_VERSION) : kernelVersionEligible) const helperPresent = macosEligible && (dependencies.isCuHelperAvailable ?? isCuHelperAvailableForServer)() const capability = resolveComputerUseCapability( platform, systemVersion, helperPresent, kernelVersionEligible, ) const supported = capability.supported // macOS has a self-contained native runtime. Its status must never wait for // a stale custom Python executable or expose the retired setup flow. if (platform === 'darwin') { const permissions = capability.cuHelper.available ? await (dependencies.checkPermissions ?? checkCuHelperPermissions)() : { accessibility: null, screenRecording: null, error: null } return { platform, supported, engine: capability.engine, systemVersion, arch, cuHelper: capability.cuHelper, python: { installed: false, version: null, path: null, source: null, error: null }, venv: { created: false, path: venvRoot }, dependencies: { installed: false, requirementsFound: false }, permissions, } } // Check venv — different paths on Windows vs Unix const venvPython = isWindows ? join(venvRoot, 'Scripts', 'python.exe') : join(venvRoot, 'bin', 'python3') const venvCreated = await pathExists(venvPython) const config = await loadConfig() const pythonRuntime = await detectPythonRuntime( platform, runCommand, venvCreated ? venvPython : undefined, config.pythonPath, ) // 校验现有 venv 是否与当前 config.pythonPath 配置一致: // - 老用户从未设过自定义路径 → marker 不存在 + current 为空 → 视为匹配, // 原行为完全不变。 // - 配置变更(设置/切换/清空自定义路径)→ marker 与 current 不一致 → // effectiveVenvCreated 置为 false,UI 提示需要重新 setup。 let effectiveVenvCreated = venvCreated if (venvCreated) { const matches = await venvBaseInterpreterMatches(config.pythonPath) if (!matches) effectiveVenvCreated = false } // Check dependencies — use the state dir copy const reqPath = getRequirementsPath() const requirementsFound = await pathExists(reqPath) let depsInstalled = false if (requirementsFound && effectiveVenvCreated) { try { const requirements = await readFile(reqPath, 'utf8') const digest = createHash('sha256').update(requirements).digest('hex') const stamp = (await readFile(installStampPath, 'utf8')).trim() depsInstalled = stamp === digest } catch { depsInstalled = false } } // Check OS permissions without triggering a system prompt. let accessibility: boolean | null = null let screenRecording: boolean | null = null // macOS-native path: when the Swift cu-helper is present, report permissions // straight from its `check_permissions` snapshot, with NO Python prerequisite. // This is what lets the new settings UI show 辅助功能 / 屏幕录制 status even // before (or entirely without) a Python venv. const cuHelperAvailable = capability.cuHelper.available if (cuHelperAvailable) { const perms = await checkCuHelperPermissions() accessibility = perms.accessibility screenRecording = perms.screenRecording } else if (capability.engine === 'windows-compat' && effectiveVenvCreated && depsInstalled) { // Python path (Windows, or macOS without cu-helper). The helper uses // preflight + visible-window metadata as a passive fallback because plain // preflight can misreport child processes launched by the desktop app. try { await ensureRuntimeFiles() } catch {} const helperPath = getHelperPath() if (await pathExists(helperPath)) { const permResult = await runCommand(venvPython, [helperPath, 'check_permissions']) if (permResult.ok) { try { const parsed = JSON.parse(permResult.stdout) if (parsed.ok && parsed.result) { accessibility = parsed.result.accessibility ?? null screenRecording = parsed.result.screenRecording ?? null } } catch {} } } } return { platform, supported, engine: capability.engine, systemVersion, arch, cuHelper: capability.cuHelper, python: { installed: pythonRuntime.installed, version: pythonRuntime.version, path: pythonRuntime.path, source: pythonRuntime.source, error: pythonRuntime.error, }, venv: { created: effectiveVenvCreated, path: venvRoot }, dependencies: { installed: depsInstalled, requirementsFound: requirementsFound || true }, permissions: { accessibility, screenRecording, error: null }, } } type SetupResult = { success: boolean steps: { name: string; ok: boolean; message: string }[] } export function getUnsupportedComputerUsePlatformStep( platform: string, ): SetupResult['steps'][number] | null { if (platform === 'darwin' || platform === 'win32') return null return { name: 'platform', ok: false, message: `Computer Use does not support platform: ${platform}`, } } export function getUnsupportedPythonVersionStep( version: string | null, ): SetupResult['steps'][number] | null { if (isPythonVersionAtLeast(version, MIN_PYTHON_MAJOR, MIN_PYTHON_MINOR)) return null return { name: 'python_version', ok: false, message: `Computer Use 需要 Python >= ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR},当前版本为 ${version ?? 'unknown'}`, } } export async function installSetupDependencies( venvPython: string, reqPath: string, install: typeof runPipInstallWithFallback = runPipInstallWithFallback, ): Promise<{ ok: boolean; stdout: string; stderr: string; code: number }> { await install(venvPython, ['-m', 'pip', 'install', '--upgrade', 'pip']) return install(venvPython, ['-m', 'pip', 'install', '-r', reqPath]) } async function runSetup(): Promise { const steps: SetupResult['steps'] = [] if (process.platform === 'darwin') { return { success: false, steps: [{ name: 'native_runtime', ok: false, message: 'Computer Use on macOS uses the built-in native runtime and does not require Python setup.', }], } } const unsupportedPlatformStep = getUnsupportedComputerUsePlatformStep(process.platform) if (unsupportedPlatformStep) { return { success: false, steps: [unsupportedPlatformStep] } } const venvPython = isWindows ? join(venvRoot, 'Scripts', 'python.exe') : join(venvRoot, 'bin', 'python3') let venvExists = await pathExists(venvPython) const config = await loadConfig() // 校验现有 venv 是否与当前 config.pythonPath 配置一致(marker 机制详见 // venvBaseInterpreterMatches 注释)。不一致则删除以便后续步骤重建。 // 老用户从未设过自定义路径时此分支不会触发,原行为完全保留。 if (venvExists && !(await venvBaseInterpreterMatches(config.pythonPath))) { try { await rm(venvRoot, { recursive: true, force: true }) // 同时清除 stamp,否则 Step 5 会因 digest 匹配而跳过依赖安装, // 导致重建出的 venv 没有依赖。 await rm(installStampPath, { force: true }) await rm(baseInterpreterMarkerPath, { force: true }) venvExists = false steps.push({ name: 'venv_rebuild', ok: true, message: '检测到自定义解释器变更,已移除旧虚拟环境以便重建', }) } catch (err) { steps.push({ name: 'venv_rebuild', ok: false, message: `移除旧虚拟环境失败: ${err}`, }) return { success: false, steps } } } // Step 1: Check python const pythonRuntime = await detectPythonRuntime( process.platform, runCommand, venvExists ? venvPython : undefined, config.pythonPath, ) if (!pythonRuntime.installed) { steps.push({ name: 'python_check', ok: false, message: pythonRuntime.source === 'custom' ? `自定义 Python 路径不可用: ${pythonRuntime.error ?? pythonRuntime.path}` : 'Python 3 未安装,请先安装 Python 3', }) return { success: false, steps } } steps.push({ name: 'python_check', ok: true, message: pythonRuntime.source === 'custom' ? `Python ${pythonRuntime.version}(使用自定义解释器)` : pythonRuntime.source === 'venv' ? `Python ${pythonRuntime.version}(使用现有虚拟环境)` : `Python ${pythonRuntime.version}`, }) const unsupportedVersionStep = getUnsupportedPythonVersionStep(pythonRuntime.version) if (unsupportedVersionStep) return { success: false, steps: [...steps, unsupportedVersionStep] } // Step 2: Extract runtime files to ~/.claude/.runtime/ try { await ensureRuntimeFiles() steps.push({ name: 'runtime_files', ok: true, message: '运行时文件已就绪' }) } catch (err) { steps.push({ name: 'runtime_files', ok: false, message: `提取运行时文件失败: ${err}`, }) return { success: false, steps } } // Step 3: Create venv if (!venvExists) { if (!pythonRuntime.command) { steps.push({ name: 'venv', ok: false, message: '未找到可用于创建虚拟环境的 Python 命令', }) return { success: false, steps } } const venvResult = await runCommand(pythonRuntime.command, [ ...pythonRuntime.prefixArgs, '-m', 'venv', venvRoot, ]) if (!venvResult.ok) { steps.push({ name: 'venv', ok: false, message: `创建虚拟环境失败: ${venvResult.stderr}`, }) return { success: false, steps } } // 记录本次创建 venv 时所用的自定义路径配置,供后续 checkStatus 比对。 try { await writeFile(baseInterpreterMarkerPath, config.pythonPath ?? '', 'utf8') } catch (err) { steps.push({ name: 'venv', ok: false, message: `写入虚拟环境标记文件失败: ${err}`, }) return { success: false, steps } } steps.push({ name: 'venv', ok: true, message: '虚拟环境已创建' }) } else { steps.push({ name: 'venv', ok: true, message: '虚拟环境已存在' }) } // Step 4: Ensure pip const pipPath = isWindows ? join(venvRoot, 'Scripts', 'pip.exe') : join(venvRoot, 'bin', 'pip') if (!(await pathExists(pipPath))) { const pipResult = await runCommand(venvPython, [ '-m', 'ensurepip', '--upgrade', ]) if (!pipResult.ok) { steps.push({ name: 'pip', ok: false, message: `安装 pip 失败: ${pipResult.stderr}`, }) return { success: false, steps } } } steps.push({ name: 'pip', ok: true, message: 'pip 已就绪' }) // Step 5: Install requirements const reqPath = getRequirementsPath() const requirements = await readFile(reqPath, 'utf8') const digest = createHash('sha256').update(requirements).digest('hex') let installedDigest = '' try { installedDigest = (await readFile(installStampPath, 'utf8')).trim() } catch {} if (installedDigest !== digest) { const installResult = await installSetupDependencies(venvPython, reqPath) if (!installResult.ok) { steps.push({ name: 'deps', ok: false, message: `安装依赖失败: ${installResult.stderr.slice(0, 500)}`, }) return { success: false, steps } } await writeFile(installStampPath, `${digest}\n`, 'utf8') steps.push({ name: 'deps', ok: true, message: '依赖已安装' }) } else { steps.push({ name: 'deps', ok: true, message: '依赖已是最新' }) } return { success: true, steps } } // ============================================================================ // Authorized Apps configuration — stored in ~/.claude/cc-haha/computer-use-config.json // ============================================================================ type AuthorizedApp = { bundleId: string displayName: string authorizedAt?: string [key: string]: unknown } type ComputerUseConfig = { enabled: boolean authorizedApps: AuthorizedApp[] grantFlags: { clipboardRead: boolean clipboardWrite: boolean systemKeyCombos: boolean } pythonPath: string | null } export type ComputerUseConfigPatch = { enabled?: boolean authorizedApps?: AuthorizedApp[] grantFlags?: Partial pythonPath?: string | null } type RequestAccessBody = { sessionId?: string request?: CuPermissionRequest } const DEFAULT_CONFIG: ComputerUseConfig = { enabled: true, authorizedApps: [], grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS, pythonPath: null, } async function loadConfig(): Promise { return { ...DEFAULT_CONFIG, ...(await loadStoredComputerUseConfig()) } } async function saveConfig(config: ComputerUseConfig): Promise { await saveStoredComputerUseConfig(config) // Deliberately no cache invalidation here. This runs in the SERVER process; // the `computer-use` skill's gate and the memoized command list both live in // the CLI child process, so clearing anything from here reaches nothing. // The CLI picks the change up by watching this file — see skillChangeDetector. } function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } const CONFIG_PATCH_KEYS = new Set([ 'enabled', 'authorizedApps', 'grantFlags', 'pythonPath', ]) const GRANT_FLAG_KEYS = new Set([ 'clipboardRead', 'clipboardWrite', 'systemKeyCombos', ]) export function parseComputerUseConfigPatch( value: unknown, ): ComputerUseConfigPatch | null { if (!isPlainObject(value)) return null if (Object.keys(value).some(key => !CONFIG_PATCH_KEYS.has(key))) return null if (value.enabled !== undefined && typeof value.enabled !== 'boolean') return null let authorizedApps: AuthorizedApp[] | undefined if (value.authorizedApps !== undefined) { if (!Array.isArray(value.authorizedApps)) return null authorizedApps = [] for (const candidate of value.authorizedApps) { if (!isPlainObject(candidate)) return null if ( typeof candidate.bundleId !== 'string' || typeof candidate.displayName !== 'string' ) { return null } const bundleId = candidate.bundleId.trim() const displayName = candidate.displayName.trim() if (!bundleId || !displayName) return null if ( candidate.authorizedAt !== undefined && typeof candidate.authorizedAt !== 'string' ) { return null } authorizedApps.push({ ...candidate, bundleId, displayName, }) } } let grantFlags: ComputerUseConfigPatch['grantFlags'] if (value.grantFlags !== undefined) { if ( !isPlainObject(value.grantFlags) || Object.keys(value.grantFlags).some(key => !GRANT_FLAG_KEYS.has(key)) ) { return null } grantFlags = {} for (const key of GRANT_FLAG_KEYS) { const flag = value.grantFlags[key] if (flag !== undefined && typeof flag !== 'boolean') return null if (typeof flag === 'boolean') grantFlags[key] = flag } } if ( value.pythonPath !== undefined && value.pythonPath !== null && ( typeof value.pythonPath !== 'string' ) ) { return null } return { ...(value.enabled === undefined ? {} : { enabled: value.enabled }), ...(authorizedApps === undefined ? {} : { authorizedApps }), ...(grantFlags === undefined ? {} : { grantFlags }), ...(value.pythonPath === undefined ? {} : { pythonPath: normalizePythonPath(value.pythonPath) }), } } export async function openComputerUseSettings( platform: string, pane: 'Privacy_ScreenCapture' | 'Privacy_Accessibility', run: typeof runCommand = runCommand, ): Promise<{ ok: boolean; message?: string }> { const command = platform === 'darwin' ? { cmd: 'open', args: [`x-apple.systempreferences:com.apple.preference.security?${pane}`], } : platform === 'win32' ? { cmd: 'cmd', args: ['/c', 'start', 'ms-settings:privacy'] } : null if (!command) return { ok: false, message: 'Unsupported platform' } const result = await run(command.cmd, command.args) return result.ok ? { ok: true } : { ok: false, message: result.stderr || `Failed to run ${command.cmd}` } } /** * Compute the AuthorizedApp entries to APPEND for a batch of runtime grants, * deduped by bundleId against the already-stored apps. Pure (no I/O) so it can * be unit-tested directly. * * Mapping: AppGrant.grantedAt (epoch ms) -> AuthorizedApp.authorizedAt (ISO * string). NOTE the stored schema (StoredAuthorizedApp) carries no `tier`, so * a runtime read/click-tier grant is reloaded as full-tier next session — a * pre-existing limitation of the stored schema, out of scope here. */ export function computeRuntimeGrantAdditions( existingApps: AuthorizedApp[], granted: AppGrant[], ): AuthorizedApp[] { const existing = new Set(existingApps.map((app) => app.bundleId)) const additions: AuthorizedApp[] = [] // Dedupe against BOTH the stored set and within this batch, so a request // listing the same bundleId twice can't double-append. const seen = new Set(existing) for (const grant of granted) { if (!grant.bundleId || seen.has(grant.bundleId)) continue seen.add(grant.bundleId) additions.push({ bundleId: grant.bundleId, displayName: grant.displayName, authorizedAt: new Date(grant.grantedAt).toISOString(), }) } return additions } /** * Append newly-granted apps (from a runtime request_access approval) to the * persisted config. Best-effort: any failure is swallowed so it can never tank * the grant response the model is awaiting. Idempotent across repeat calls in a * long session because additions are deduped by bundleId. */ async function persistRuntimeGrants(granted: AppGrant[]): Promise { if (!granted || granted.length === 0) return try { const config = await loadConfig() const additions = computeRuntimeGrantAdditions(config.authorizedApps, granted) if (additions.length === 0) return config.authorizedApps = [...config.authorizedApps, ...additions] await saveConfig(config) } catch { // best-effort — a config-write failure must NOT fail the grant response } } async function listInstalledApps(): Promise<{ bundleId: string; displayName: string; path: string }[]> { // macOS: enumerate the application roots directly. This needs neither a // Python venv nor a running helper, so the app picker populates on a cold // install — before Computer Use has ever been granted anything. if (process.platform === 'darwin') { const apps = await listInstalledMacApps() if (apps.length > 0) return apps // The native helper is the only macOS fallback. Never cross into the // retired Python runtime when native app enumeration is temporarily empty. if (isCuHelperAvailableForServer()) return listInstalledAppsViaCuHelper() return [] } const helperPath = getHelperPath() const pythonBin = isWindows ? join(venvRoot, 'Scripts', 'python.exe') : join(venvRoot, 'bin', 'python3') if (!(await pathExists(pythonBin)) || !(await pathExists(helperPath))) { return [] } const result = await runCommand(pythonBin, [helperPath, 'list_installed_apps']) if (!result.ok) return [] try { const parsed = JSON.parse(result.stdout) return parsed.ok ? parsed.result : [] } catch { return [] } } /** * Map a bundle id to its installed bundle path. * * Enumerating applications walks several directory trees, and the picker asks * for one icon per visible row, so the mapping is cached briefly. The window is * short enough that an app installed while the picker is open still appears on * the next open, and long enough that a scroll through hundreds of rows scans * once rather than once per row. */ const APP_PATH_CACHE_TTL_MS = 30_000 let appPathCache: { at: number; byBundleId: Map } | null = null let appPathScan: Promise> | null = null export async function resolveInstalledAppPath( bundleId: string, // Injected so a test can count scans without walking the real disk. lister: () => Promise<{ bundleId: string; path: string }[]> = listInstalledApps, ): Promise { const cached = appPathCache if (cached && Date.now() - cached.at <= APP_PATH_CACHE_TTL_MS) { return cached.byBundleId.get(bundleId) ?? null } // Share one scan across concurrent callers. Opening the picker fires an icon // request per visible row at once, and a plain check-then-fill cache is still // cold for all of them — each would launch its own enumeration of every // application root. if (!appPathScan) { appPathScan = (async () => { try { const apps = await lister() const byBundleId = new Map(apps.map(app => [app.bundleId, app.path])) appPathCache = { at: Date.now(), byBundleId } return byBundleId } finally { appPathScan = null } })() } return (await appPathScan).get(bundleId) ?? null } /** Test hook: forget the bundle-id mapping between cases. */ export function __resetInstalledAppPathCacheForTests(): void { appPathCache = null appPathScan = null } // ============================================================================ // Route handler // ============================================================================ export async function handleComputerUseApi( req: Request, url: URL, segments: string[], ): Promise { const action = segments[2] if (action === 'status' && req.method === 'GET') { const status = await checkStatus() return Response.json(status) } if (action === 'setup' && req.method === 'POST') { const result = await runSetup() return Response.json(result) } // GET /api/computer-use/apps — list installed macOS apps if (action === 'apps' && req.method === 'GET') { const apps = await listInstalledApps() return Response.json({ apps }) } // GET /api/computer-use/app-icon?bundleId=…&size=… — the app's own icon. // // The parameter is a bundle id, never a path: the server resolves it against // the installed-app enumeration, so a caller cannot name an arbitrary file // and have it rasterised and returned. if (action === 'app-icon' && req.method === 'GET') { if (process.platform !== 'darwin') { return new Response('Not found', { status: 404 }) } const bundleId = url.searchParams.get('bundleId')?.trim() if (!bundleId) { return Response.json({ error: 'bundleId is required' }, { status: 400 }) } const appPath = await resolveInstalledAppPath(bundleId) if (!appPath) return new Response('Not found', { status: 404 }) const size = normalizeIconSize(url.searchParams.get('size')) const png = await readAppIconPng(appPath, size) // A bundle with no icon is an ordinary outcome, not a failure — the row // renders its letter placeholder when this 404s. if (!png) return new Response('Not found', { status: 404 }) return new Response(png as unknown as BodyInit, { headers: { 'Content-Type': 'image/png', // Icons change only when an app is reinstalled; the server keeps its // own cache too, this just stops the picker refetching while scrolling. 'Cache-Control': 'private, max-age=3600', }, }) } // GET /api/computer-use/authorized-apps — current authorized app config if (action === 'authorized-apps' && req.method === 'GET') { const result = await loadStoredComputerUseConfigResult() if (result.error) { const configPath = getComputerUseConfigPath() return Response.json( { error: 'COMPUTER_USE_CONFIG_INVALID', message: `Computer Use config could not be loaded: ${result.error}`, configPath, recoveryHint: '删除或修复该文件即可恢复', }, { status: 500 }, ) } return Response.json(result.config) } // PUT /api/computer-use/authorized-apps — update authorized apps if (action === 'authorized-apps' && req.method === 'PUT') { let body: ComputerUseConfigPatch | null = null try { body = parseComputerUseConfigPatch(await req.json()) } catch {} if (!body) { return Response.json( { error: 'INVALID_COMPUTER_USE_CONFIG', message: 'Invalid Computer Use config' }, { status: 400 }, ) } const stored = await loadStoredComputerUseConfigResult() if (stored.error) { const configPath = getComputerUseConfigPath() return Response.json( { error: 'COMPUTER_USE_CONFIG_INVALID', message: `Computer Use config must be repaired before it can be changed: ${stored.error}`, configPath, recoveryHint: '删除或修复该文件即可恢复', }, { status: 409 }, ) } const config = stored.config if (body.enabled !== undefined) config.enabled = body.enabled if (body.authorizedApps !== undefined) config.authorizedApps = body.authorizedApps if (body.grantFlags !== undefined) { config.grantFlags = { ...config.grantFlags, ...body.grantFlags } } if ('pythonPath' in body) config.pythonPath = body.pythonPath ?? null try { await saveConfig(config) return Response.json({ ok: true }) } catch { return Response.json( { error: 'COMPUTER_USE_CONFIG_WRITE_FAILED', message: 'Could not save Computer Use config' }, { status: 500 }, ) } } // POST /api/computer-use/open-settings — open system settings pane if (action === 'open-settings' && req.method === 'POST') { let body: unknown try { body = await req.json() } catch { return Response.json( { error: 'INVALID_OPEN_SETTINGS_REQUEST', message: 'Invalid settings request' }, { status: 400 }, ) } if (!isPlainObject(body)) { return Response.json( { error: 'INVALID_OPEN_SETTINGS_REQUEST', message: 'Invalid settings request' }, { status: 400 }, ) } const pane = body.pane ?? 'Privacy_ScreenCapture' const allowed = ['Privacy_ScreenCapture', 'Privacy_Accessibility'] if (typeof pane !== 'string' || !allowed.includes(pane)) { return Response.json({ error: 'Invalid pane' }, { status: 400 }) } const result = await openComputerUseSettings( process.platform, pane as 'Privacy_ScreenCapture' | 'Privacy_Accessibility', ) if (!result.ok) { const supportedPlatform = process.platform === 'darwin' || process.platform === 'win32' return Response.json( { error: supportedPlatform ? 'OPEN_SETTINGS_FAILED' : 'UNSUPPORTED_PLATFORM', message: supportedPlatform ? 'Could not open system settings' : 'Computer Use is not supported on this platform', }, { status: supportedPlatform ? 500 : 400 }, ) } return Response.json({ ok: true }) } // POST /api/computer-use/permission-card (alias: open-permission-card) // macOS only: pop the native cu-helper authorization card and await its // post-close snapshot. The promise resolves only when the user CLOSES the // card, so the client uses a long timeout. No-op { ok:false, reason } off // darwin or when the binary is missing. if ( (action === 'permission-card' || action === 'open-permission-card') && req.method === 'POST' ) { const result = await openNativePermissionCard() return Response.json(result) } if (action === 'request-access' && req.method === 'POST') { try { const body = (await req.json()) as RequestAccessBody if (!body.sessionId || !body.request?.requestId) { return Response.json( { error: 'BAD_REQUEST', message: 'sessionId and request are required' }, { status: 400 }, ) } const response = await computerUseApprovalService.requestApproval( body.sessionId, body.request, ) // Runtime auto-add: persist apps the user just granted into the stored // config so they appear under "始终允许的应用" in Settings and survive // into the next session. This is the SINGLE server-side runtime-grant // ingress (no teach-access route exists), runs in the process that owns // the config writer, and does NOT touch the per-session allowlist (that // lives in the separate CLI subprocess). Best-effort: a config-write // failure must never fail the grant the model is awaiting. await persistRuntimeGrants(response.granted) return Response.json(response) } catch (error) { const message = error instanceof Error ? error.message : 'Computer Use approval failed' const status = message.includes('not connected') ? 409 : 500 return Response.json({ error: 'COMPUTER_USE_APPROVAL_FAILED', message }, { status }) } } return Response.json( { error: 'NOT_FOUND', message: `Unknown computer-use action: ${action}` }, { status: 404 }, ) }