codev / src /server /api /computer-use.ts
chenbhao's picture
feat: desktop
1f21206
Raw
History Blame Contribute Delete
21.4 kB
/**
* 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 { CuPermissionRequest } from '../../vendor/computer-use-mcp/types.js'
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
import { detectPythonRuntime, isPythonVersionAtLeast } from './computer-use-python.js'
import { buildPipInstallAttempts } from '../../utils/computerUse/pipInstall.js'
import {
DEFAULT_DESKTOP_GRANT_FLAGS,
loadStoredComputerUseConfig,
normalizePythonPath,
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' }
// @ts-ignore — Bun text import
import WIN_HELPER_CONTENT from '../../../runtime/win_helper.py' with { type: 'text' }
// @ts-ignore — Bun text import
import REQUIREMENTS_DARWIN from '../../../runtime/requirements.txt' 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
const isWindows = process.platform === 'win32'
const REQUIREMENTS_CONTENT = isWindows ? REQUIREMENTS_WIN32 : REQUIREMENTS_DARWIN
function getPythonCommandEnv(): Record<string, string> | undefined {
if (!isWindows) return undefined
return {
...process.env,
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1',
} as Record<string, string>
}
// Paths that resolve correctly in both dev and bundled modes
function getRequirementsPath(): string {
return join(runtimeStateRoot, 'requirements.txt')
}
function getHelperFileName(): string {
return isWindows ? 'win_helper.py' : 'mac_helper.py'
}
function getHelperPath(): string {
return join(runtimeStateRoot, getHelperFileName())
}
async function pathExists(target: string): Promise<boolean> {
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<boolean> {
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 runtime source files (requirements.txt, mac_helper.py) exist in
* ~/.claude/.runtime/. In dev mode they are copied from the project's
* runtime/ directory; in bundled mode requirements.txt is written from the
* embedded constant and mac_helper.py is copied from the project dir (if
* available) or skipped (it will already have been extracted on a prior run).
*/
async function ensureRuntimeFiles(): Promise<void> {
await mkdir(runtimeStateRoot, { recursive: true })
// requirements.txt — always write from embedded constant (authoritative)
await writeFile(getRequirementsPath(), REQUIREMENTS_CONTENT, 'utf8')
// helper script — write the platform-appropriate version
const helperContent = isWindows ? WIN_HELPER_CONTENT : MAC_HELPER_CONTENT
await writeFile(getHelperPath(), helperContent, 'utf8')
}
type EnvStatus = {
platform: string
supported: boolean
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
}
}
async function checkStatus(): Promise<EnvStatus> {
const platform = process.platform
const supported = platform === 'darwin' || platform === 'win32'
// 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 macOS permissions without triggering a system prompt. The helper
// uses preflight + visible-window metadata as a passive fallback because
// plain preflight can misreport child processes launched by the desktop app.
let accessibility: boolean | null = null
let screenRecording: boolean | null = null
if (supported && effectiveVenvCreated && depsInstalled) {
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,
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 },
}
}
type SetupResult = {
success: boolean
steps: { name: string; ok: boolean; message: string }[]
}
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<SetupResult> {
const steps: SetupResult['steps'] = []
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
}
type ComputerUseConfig = {
enabled: boolean
authorizedApps: AuthorizedApp[]
grantFlags: {
clipboardRead: boolean
clipboardWrite: boolean
systemKeyCombos: boolean
}
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<ComputerUseConfig> {
return { ...DEFAULT_CONFIG, ...(await loadStoredComputerUseConfig()) }
}
async function saveConfig(config: ComputerUseConfig): Promise<void> {
await saveStoredComputerUseConfig(config)
}
async function listInstalledApps(): Promise<{ bundleId: string; displayName: string; path: string }[]> {
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 []
}
}
// ============================================================================
// Route handler
// ============================================================================
export async function handleComputerUseApi(
req: Request,
_url: URL,
segments: string[],
): Promise<Response> {
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/authorized-apps — current authorized app config
if (action === 'authorized-apps' && req.method === 'GET') {
const config = await loadConfig()
return Response.json(config)
}
// PUT /api/computer-use/authorized-apps — update authorized apps
if (action === 'authorized-apps' && req.method === 'PUT') {
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 }
if ('pythonPath' in body) config.pythonPath = normalizePythonPath(body.pythonPath)
await saveConfig(config)
return Response.json({ ok: true })
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
}
}
// POST /api/computer-use/open-settings — open system settings pane
if (action === 'open-settings' && req.method === 'POST') {
const body = (await req.json().catch(() => ({}))) as { pane?: string }
const pane = body.pane ?? 'Privacy_ScreenCapture'
const allowed = ['Privacy_ScreenCapture', 'Privacy_Accessibility']
if (!allowed.includes(pane)) {
return Response.json({ error: 'Invalid pane' }, { status: 400 })
}
if (process.platform === 'darwin') {
const url = `x-apple.systempreferences:com.apple.preference.security?${pane}`
await runCommand('open', [url])
} else if (process.platform === 'win32') {
// Windows doesn't need privacy settings like macOS TCC, but we can
// open the general privacy page if requested
await runCommand('cmd', ['/c', 'start', 'ms-settings:privacy'])
} else {
return Response.json({ error: 'Unsupported platform' }, { status: 400 })
}
return Response.json({ ok: true })
}
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,
)
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 },
)
}