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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-22 19:16:45 +08:00
parent 6b62b6c633
commit d9bd75b5e5
10 changed files with 288 additions and 38 deletions

View File

@ -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

View File

@ -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

View File

@ -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<Response> {
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<Respo
)
}
beforeEach(async () => {
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 })
})
})

View File

@ -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 () => {

View File

@ -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')
})
})

View File

@ -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/)

View File

@ -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<string, string> | undefined {
} 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')
@ -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<SetupResult> {
const steps: SetupResult['steps'] = []
@ -318,6 +351,9 @@ async function runSetup(): Promise<SetupResult> {
: `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<SetupResult> {
} 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',

View File

@ -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',
])
})
})

View File

@ -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,
]
}

View File

@ -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<void> {
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<void> {
await install(['-m', 'pip', 'install', '--upgrade', 'pip'], 'pip upgrade')
await install(['-m', 'pip', 'install', '-r', requirementsPath], 'python dependency install')
}
async function getVenvCreationPythonCommand(): Promise<string> {
const config = await loadStoredComputerUseConfig()
if (config.pythonPath) return config.pythonPath
@ -120,16 +143,7 @@ export async function ensureBootstrapped(): Promise<void> {
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')
}
})()