mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
Computer Use now rejects Python runtimes below 3.9 before running pip, keeps Pillow on the Python 3.9-compatible 11.x line, and falls back from the configured mirror to the default PyPI index when dependency installs fail. Constraint: Pillow 11.3 requires Python 3.9+, while user machines may resolve a different Python than expected Rejected: Downgrade Pillow to the Python 3.8-compatible 10.x line | current product direction assumes modern Python installs Confidence: high Scope-risk: narrow Directive: Keep runtime package ranges aligned with the setup Python minimum before changing either side Tested: bun test src/server/__tests__/computer-use-api.test.ts src/server/__tests__/computer-use-requirements.test.ts src/server/__tests__/computer-use-python.test.ts src/utils/computerUse/pipInstall.test.ts Tested: bun run check:server Tested: bun run check:coverage Tested: bun run check:policy Not-tested: Real Windows 10 machine dependency install against the reported user environment
621 lines
21 KiB
TypeScript
621 lines
21 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, 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 },
|
||
)
|
||
}
|