From d9bd75b5e5217be741596a3837b367f366a85d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 22 May 2026 19:16:45 +0800 Subject: [PATCH] fix: stabilize Computer Use dependency installs 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 --- runtime/requirements-win.txt | 2 +- runtime/requirements.txt | 2 +- src/server/__tests__/computer-use-api.test.ts | 95 ++++++++++++++++++- .../__tests__/computer-use-python.test.ts | 15 ++- .../computer-use-requirements.test.ts | 5 + src/server/api/computer-use-python.ts | 13 +++ src/server/api/computer-use.ts | 59 ++++++++---- src/utils/computerUse/pipInstall.test.ts | 82 ++++++++++++++++ src/utils/computerUse/pipInstall.ts | 13 +++ src/utils/computerUse/pythonBridge.ts | 40 +++++--- 10 files changed, 288 insertions(+), 38 deletions(-) create mode 100644 src/utils/computerUse/pipInstall.test.ts create mode 100644 src/utils/computerUse/pipInstall.ts diff --git a/runtime/requirements-win.txt b/runtime/requirements-win.txt index 2e736c96..b4f7d06a 100644 --- a/runtime/requirements-win.txt +++ b/runtime/requirements-win.txt @@ -1,5 +1,5 @@ mss>=9.0.2,<10 -Pillow>=11.3.0 +Pillow>=11.3.0,<12 pyautogui>=0.9.54 pywin32>=306 psutil>=5.9.0 diff --git a/runtime/requirements.txt b/runtime/requirements.txt index 1f49b657..41b08e5d 100644 --- a/runtime/requirements.txt +++ b/runtime/requirements.txt @@ -1,5 +1,5 @@ mss>=9.0.2,<10 -Pillow>=11.3.0 +Pillow>=11.3.0,<12 pyautogui>=0.9.54 pyobjc-core>=11.1 pyobjc-framework-Cocoa>=11.1 diff --git a/src/server/__tests__/computer-use-api.test.ts b/src/server/__tests__/computer-use-api.test.ts index 528ca8a8..4235b7fd 100644 --- a/src/server/__tests__/computer-use-api.test.ts +++ b/src/server/__tests__/computer-use-api.test.ts @@ -1,12 +1,16 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { handleComputerUseApi } from '../api/computer-use.js' - const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR let configDir: string | null = null +let computerUseApi: typeof import('../api/computer-use.js') | null = null + +async function importComputerUseApi() { + if (!computerUseApi) throw new Error('Computer Use API module was not initialized') + return computerUseApi +} function makeRequest(method: string, body?: unknown): Request { return new Request('http://localhost/api/computer-use/authorized-apps', { @@ -16,6 +20,7 @@ function makeRequest(method: string, body?: unknown): Request { } async function callAuthorizedApps(method: string, body?: unknown): Promise { + const { handleComputerUseApi } = await importComputerUseApi() return handleComputerUseApi( makeRequest(method, body), new URL('http://localhost/api/computer-use/authorized-apps'), @@ -23,12 +28,20 @@ async function callAuthorizedApps(method: string, body?: unknown): Promise { +beforeAll(async () => { configDir = await mkdtemp(join(tmpdir(), 'cc-haha-computer-use-api-')) process.env.CLAUDE_CONFIG_DIR = configDir + computerUseApi = await import('../api/computer-use.js') }) -afterEach(async () => { +beforeEach(async () => { + if (!configDir) throw new Error('configDir was not initialized') + process.env.CLAUDE_CONFIG_DIR = configDir + await rm(join(configDir, 'cc-haha'), { recursive: true, force: true }) + await rm(join(configDir, '.runtime'), { recursive: true, force: true }) +}) + +afterAll(async () => { if (originalClaudeConfigDir === undefined) { delete process.env.CLAUDE_CONFIG_DIR } else { @@ -83,3 +96,75 @@ describe('Computer Use API authorized app config', () => { expect(await resetGetRes.json()).toMatchObject({ pythonPath: null }) }) }) + +describe('runPipInstallWithFallback', () => { + it('builds a clear unsupported Python version step for setup', async () => { + const { getUnsupportedPythonVersionStep } = await importComputerUseApi() + + expect(getUnsupportedPythonVersionStep('3.8.18')).toEqual({ + name: 'python_version', + ok: false, + message: 'Computer Use 需要 Python >= 3.9,当前版本为 3.8.18', + }) + expect(getUnsupportedPythonVersionStep('3.9.19')).toBeNull() + }) + + it('installs setup dependencies by upgrading pip before requirements', async () => { + const { installSetupDependencies } = await importComputerUseApi() + const calls: string[] = [] + + const result = await installSetupDependencies( + 'python', + '/tmp/requirements.txt', + async (cmd, args) => { + calls.push(`${cmd} ${args.join(' ')}`) + return { ok: true, stdout: args.includes('-r') ? 'deps' : 'pip', stderr: '', code: 0 } + }, + ) + + expect(result.stdout).toBe('deps') + expect(calls).toEqual([ + 'python -m pip install --upgrade pip', + 'python -m pip install -r /tmp/requirements.txt', + ]) + }) + + it('tries the mirror first and falls back to the default PyPI index', async () => { + const { runPipInstallWithFallback } = await importComputerUseApi() + const calls: string[] = [] + const result = await runPipInstallWithFallback( + 'python', + ['-m', 'pip', 'install', '-r', 'requirements.txt'], + async (cmd, args) => { + calls.push(`${cmd} ${args.join(' ')}`) + if (args.includes('-i')) { + return { ok: false, stdout: '', stderr: 'mirror unavailable', code: 1 } + } + return { ok: true, stdout: 'installed', stderr: '', code: 0 } + }, + ) + + expect(result.ok).toBe(true) + expect(result.stdout).toBe('installed') + expect(calls).toEqual([ + 'python -m pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/ --trusted-host pypi.tuna.tsinghua.edu.cn', + 'python -m pip install -r requirements.txt', + ]) + }) + + it('returns the first failure when every pip index attempt fails', async () => { + const { runPipInstallWithFallback } = await importComputerUseApi() + const result = await runPipInstallWithFallback( + 'python', + ['-m', 'pip', 'install', '-r', 'requirements.txt'], + async (_cmd, args) => ({ + ok: false, + stdout: '', + stderr: args.includes('-i') ? 'mirror failed' : 'default failed', + code: args.includes('-i') ? 1 : 2, + }), + ) + + expect(result).toEqual({ ok: false, stdout: '', stderr: 'mirror failed', code: 1 }) + }) +}) diff --git a/src/server/__tests__/computer-use-python.test.ts b/src/server/__tests__/computer-use-python.test.ts index 0665c09c..2963d585 100644 --- a/src/server/__tests__/computer-use-python.test.ts +++ b/src/server/__tests__/computer-use-python.test.ts @@ -1,5 +1,18 @@ import { describe, expect, test } from 'bun:test' -import { detectPythonRuntime } from '../api/computer-use-python.js' +import { detectPythonRuntime, isPythonVersionAtLeast } from '../api/computer-use-python.js' + +describe('isPythonVersionAtLeast', () => { + test('accepts supported Python 3.9+ versions', () => { + expect(isPythonVersionAtLeast('3.9.0', 3, 9)).toBe(true) + expect(isPythonVersionAtLeast('3.12.11', 3, 9)).toBe(true) + }) + + test('rejects missing or older Python versions', () => { + expect(isPythonVersionAtLeast(null, 3, 9)).toBe(false) + expect(isPythonVersionAtLeast('3.8.18', 3, 9)).toBe(false) + expect(isPythonVersionAtLeast('2.7.18', 3, 9)).toBe(false) + }) +}) describe('detectPythonRuntime', () => { test('prefers python3 on Windows when available', async () => { diff --git a/src/server/__tests__/computer-use-requirements.test.ts b/src/server/__tests__/computer-use-requirements.test.ts index 17eb9e43..57f9261d 100644 --- a/src/server/__tests__/computer-use-requirements.test.ts +++ b/src/server/__tests__/computer-use-requirements.test.ts @@ -15,4 +15,9 @@ describe('computer use requirements', () => { expect(findRequirement(darwinRequirements, 'mss')).toBe('mss>=9.0.2,<10') expect(findRequirement(win32Requirements, 'mss')).toBe('mss>=9.0.2,<10') }) + + test('pins Pillow to the Python 3.9-compatible 11.x major line', () => { + expect(findRequirement(darwinRequirements, 'Pillow')).toBe('Pillow>=11.3.0,<12') + expect(findRequirement(win32Requirements, 'Pillow')).toBe('Pillow>=11.3.0,<12') + }) }) diff --git a/src/server/api/computer-use-python.ts b/src/server/api/computer-use-python.ts index 6bda5bd4..4fb1796d 100644 --- a/src/server/api/computer-use-python.ts +++ b/src/server/api/computer-use-python.ts @@ -69,6 +69,19 @@ function extractPythonVersion(output: string): string | null { return match?.[1] ?? null } +export function isPythonVersionAtLeast( + version: string | null, + major: number, + minor: number, +): boolean { + if (!version) return false + const match = version.match(/^(\d+)\.(\d+)/) + if (!match) return false + const currentMajor = Number(match[1]) + const currentMinor = Number(match[2]) + return currentMajor > major || (currentMajor === major && currentMinor >= minor) +} + function firstOutputLine(output: string): string | null { const line = output .split(/\r?\n/) diff --git a/src/server/api/computer-use.ts b/src/server/api/computer-use.ts index 276b0bf6..9df9432d 100644 --- a/src/server/api/computer-use.ts +++ b/src/server/api/computer-use.ts @@ -14,7 +14,8 @@ 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 { detectPythonRuntime, isPythonVersionAtLeast } from './computer-use-python.js' +import { buildPipInstallAttempts } from '../../utils/computerUse/pipInstall.js' import { DEFAULT_DESKTOP_GRANT_FLAGS, loadStoredComputerUseConfig, @@ -41,6 +42,8 @@ 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 @@ -54,10 +57,6 @@ function getPythonCommandEnv(): Record | undefined { } as Record } -// 清华大学 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') @@ -127,6 +126,20 @@ async function runCommand( } } +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 @@ -256,6 +269,26 @@ type SetupResult = { 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 { const steps: SetupResult['steps'] = [] @@ -318,6 +351,9 @@ async function runSetup(): Promise { : `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() @@ -403,18 +439,7 @@ async function runSetup(): Promise { } 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, - ]) + const installResult = await installSetupDependencies(venvPython, reqPath) if (!installResult.ok) { steps.push({ name: 'deps', diff --git a/src/utils/computerUse/pipInstall.test.ts b/src/utils/computerUse/pipInstall.test.ts new file mode 100644 index 00000000..9b6a4208 --- /dev/null +++ b/src/utils/computerUse/pipInstall.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test' + +import { buildPipInstallAttempts } from './pipInstall.js' +import { installRuntimeDependencies, runPipInstallWithFallback } from './pythonBridge.js' + +describe('buildPipInstallAttempts', () => { + test('tries the configured mirror before falling back to the default index', () => { + expect(buildPipInstallAttempts(['install', '-r', 'requirements.txt'])).toEqual([ + [ + 'install', + '-r', + 'requirements.txt', + '-i', + 'https://pypi.tuna.tsinghua.edu.cn/simple/', + '--trusted-host', + 'pypi.tuna.tsinghua.edu.cn', + ], + ['install', '-r', 'requirements.txt'], + ]) + }) +}) + +describe('pythonBridge runPipInstallWithFallback', () => { + test('falls back to the default PyPI index after the mirror fails', async () => { + const calls: string[][] = [] + + await runPipInstallWithFallback( + ['-m', 'pip', 'install', '--upgrade', 'pip'], + 'pip upgrade', + async args => { + calls.push(args) + return { + code: args.includes('-i') ? 1 : 0, + stdout: args.includes('-i') ? '' : 'ok', + stderr: args.includes('-i') ? 'mirror unavailable' : '', + } + }, + ) + + expect(calls).toEqual([ + [ + '-m', + 'pip', + 'install', + '--upgrade', + 'pip', + '-i', + 'https://pypi.tuna.tsinghua.edu.cn/simple/', + '--trusted-host', + 'pypi.tuna.tsinghua.edu.cn', + ], + ['-m', 'pip', 'install', '--upgrade', 'pip'], + ]) + }) + + test('throws the first pip failure when both indexes fail', async () => { + await expect(runPipInstallWithFallback( + ['-m', 'pip', 'install', '-r', 'requirements.txt'], + 'python dependency install', + async args => ({ + code: 1, + stdout: '', + stderr: args.includes('-i') ? 'mirror unavailable' : 'official unavailable', + }), + )).rejects.toThrow('python dependency install failed with code 1: mirror unavailable') + }) +}) + +describe('installRuntimeDependencies', () => { + test('upgrades pip before installing requirements', async () => { + const calls: string[] = [] + + await installRuntimeDependencies('/tmp/requirements.txt', async (args, label) => { + calls.push(`${label}: ${args.join(' ')}`) + }) + + expect(calls).toEqual([ + 'pip upgrade: -m pip install --upgrade pip', + 'python dependency install: -m pip install -r /tmp/requirements.txt', + ]) + }) +}) diff --git a/src/utils/computerUse/pipInstall.ts b/src/utils/computerUse/pipInstall.ts new file mode 100644 index 00000000..d446dc38 --- /dev/null +++ b/src/utils/computerUse/pipInstall.ts @@ -0,0 +1,13 @@ +const PIP_MIRROR_ARGS = [ + '-i', + 'https://pypi.tuna.tsinghua.edu.cn/simple/', + '--trusted-host', + 'pypi.tuna.tsinghua.edu.cn', +] + +export function buildPipInstallAttempts(baseArgs: string[]): string[][] { + return [ + [...baseArgs, ...PIP_MIRROR_ARGS], + baseArgs, + ] +} diff --git a/src/utils/computerUse/pythonBridge.ts b/src/utils/computerUse/pythonBridge.ts index 8a51174b..dd15e96a 100644 --- a/src/utils/computerUse/pythonBridge.ts +++ b/src/utils/computerUse/pythonBridge.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { execFileNoThrow } from '../execFileNoThrow.js' import { logForDebugging } from '../debug.js' import { getClaudeConfigHomeDir } from '../envUtils.js' +import { buildPipInstallAttempts } from './pipInstall.js' import { loadStoredComputerUseConfig } from './preauthorizedConfig.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -17,9 +18,6 @@ const runtimeStateRoot = path.join(getClaudeConfigHomeDir(), '.runtime') const venvRoot = path.join(runtimeStateRoot, 'venv') const installStampPath = path.join(runtimeStateRoot, 'requirements.sha256') -const PIP_INDEX_URL = 'https://pypi.tuna.tsinghua.edu.cn/simple/' -const PIP_TRUSTED_HOST = 'pypi.tuna.tsinghua.edu.cn' - const isWindows = process.platform === 'win32' // Always read from ~/.claude/.runtime/ — works in both dev and bundled mode. @@ -61,6 +59,31 @@ async function runOrThrow(file: string, args: string[], label: string): Promise< return stdout } +export async function runPipInstallWithFallback( + baseArgs: string[], + label: string, + run: (args: string[]) => Promise<{ code: number; stdout: string; stderr: string }> = args => + execFileNoThrow(pythonBinPath(), args, { useCwd: false }), +): Promise { + let firstFailure = '' + for (const args of buildPipInstallAttempts(baseArgs)) { + const { code, stdout, stderr } = await run(args) + if (code === 0) return + if (!firstFailure) { + firstFailure = `${label} failed with code ${code}: ${stderr || stdout || 'unknown error'}` + } + } + throw new Error(firstFailure || `${label} failed`) +} + +export async function installRuntimeDependencies( + requirementsPath: string, + install: typeof runPipInstallWithFallback = runPipInstallWithFallback, +): Promise { + await install(['-m', 'pip', 'install', '--upgrade', 'pip'], 'pip upgrade') + await install(['-m', 'pip', 'install', '-r', requirementsPath], 'python dependency install') +} + async function getVenvCreationPythonCommand(): Promise { const config = await loadStoredComputerUseConfig() if (config.pythonPath) return config.pythonPath @@ -120,16 +143,7 @@ export async function ensureBootstrapped(): Promise { if (installedDigest !== digest) { logForDebugging('installing python runtime dependencies', { level: 'debug' }) - await runOrThrow(pythonBinPath(), [ - '-m', 'pip', 'install', '--upgrade', 'pip', - '-i', PIP_INDEX_URL, '--trusted-host', PIP_TRUSTED_HOST, - ], 'pip upgrade') - await runOrThrow( - pythonBinPath(), - ['-m', 'pip', 'install', '-r', requirementsPath, - '-i', PIP_INDEX_URL, '--trusted-host', PIP_TRUSTED_HOST], - 'python dependency install', - ) + await installRuntimeDependencies(requirementsPath) await writeFile(installStampPath, `${digest}\n`, 'utf8') } })()