mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Computer Use setup could fail on machines where PATH discovery misses a valid Python installation, especially Windows or conda-style environments. Store an optional interpreter path, prefer it during environment checks and venv creation, and expose a desktop settings control for selecting or clearing it. Constraint: Python discovery is environment-specific and cannot always be inferred from PATH. Rejected: Continue falling back to PATH after an invalid custom path | hides a user-selected broken interpreter and makes diagnosis ambiguous. Confidence: high Scope-risk: moderate Directive: Preserve unknown Computer Use config fields and keep blank interpreter paths normalized to automatic detection. Tested: bun test src/utils/computerUse/preauthorizedConfig.test.ts src/server/__tests__/computer-use-python.test.ts src/server/__tests__/computer-use-api.test.ts Tested: cd desktop && bun run test src/pages/ComputerUseSettings.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: Computer Use browser smoke saved /opt/homebrew/bin/python3 and backend persisted then reset pythonPath to null Not-tested: Full bun run check:server; existing cron-scheduler-launcher test expects CLAUDE_CODE_ENTRYPOINT=sdk-cli but received undefined. Related: https://github.com/NanmiCoder/cc-haha/issues/331
519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
/**
|
|
* 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 } 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 } from './computer-use-python.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')
|
|
|
|
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>
|
|
}
|
|
|
|
// 清华大学 PyPI 镜像,国内安装速度更快
|
|
const PIP_INDEX_URL = 'https://pypi.tuna.tsinghua.edu.cn/simple/'
|
|
const PIP_TRUSTED_HOST = 'pypi.tuna.tsinghua.edu.cn'
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
)
|
|
|
|
// Check dependencies — use the state dir copy
|
|
const reqPath = getRequirementsPath()
|
|
const requirementsFound = await pathExists(reqPath)
|
|
let depsInstalled = false
|
|
if (requirementsFound && venvCreated) {
|
|
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 && venvCreated && 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: venvCreated, path: venvRoot },
|
|
dependencies: { installed: depsInstalled, requirementsFound: requirementsFound || true },
|
|
permissions: { accessibility, screenRecording },
|
|
}
|
|
}
|
|
|
|
type SetupResult = {
|
|
success: boolean
|
|
steps: { name: string; ok: boolean; message: string }[]
|
|
}
|
|
|
|
async function runSetup(): Promise<SetupResult> {
|
|
const steps: SetupResult['steps'] = []
|
|
|
|
const venvPython = isWindows
|
|
? join(venvRoot, 'Scripts', 'python.exe')
|
|
: join(venvRoot, 'bin', 'python3')
|
|
const venvExists = await pathExists(venvPython)
|
|
const config = await loadConfig()
|
|
|
|
// 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}`,
|
|
})
|
|
|
|
// 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 }
|
|
}
|
|
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) {
|
|
// Upgrade pip first (using China mirror)
|
|
await runCommand(venvPython, [
|
|
'-m', 'pip', 'install', '--upgrade', 'pip',
|
|
'-i', PIP_INDEX_URL, '--trusted-host', PIP_TRUSTED_HOST,
|
|
])
|
|
|
|
// Install deps (using China mirror)
|
|
const installResult = await runCommand(venvPython, [
|
|
'-m', 'pip', 'install',
|
|
'-r', reqPath,
|
|
'-i', PIP_INDEX_URL, '--trusted-host', PIP_TRUSTED_HOST,
|
|
])
|
|
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 },
|
|
)
|
|
}
|