mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat: support custom Computer Use Python interpreters
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
This commit is contained in:
parent
3193e741c7
commit
b31be5161c
@ -7,6 +7,8 @@ export type ComputerUseStatus = {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
source: 'custom' | 'system' | 'venv' | null
|
||||
error: string | null
|
||||
}
|
||||
venv: {
|
||||
created: boolean
|
||||
@ -53,6 +55,7 @@ export type ComputerUseConfig = {
|
||||
clipboardWrite: boolean
|
||||
systemKeyCombos: boolean
|
||||
}
|
||||
pythonPath: string | null
|
||||
}
|
||||
|
||||
export const computerUseApi = {
|
||||
|
||||
@ -592,6 +592,17 @@ export const en = {
|
||||
'settings.computerUse.python': 'Python 3',
|
||||
'settings.computerUse.pythonNotFound': 'Not installed. Please install Python 3 first.',
|
||||
'settings.computerUse.pythonFound': 'Installed',
|
||||
'settings.computerUse.pythonCustomInvalid': 'Custom interpreter unavailable',
|
||||
'settings.computerUse.pythonPathLabel': 'Python Interpreter Path',
|
||||
'settings.computerUse.pythonPathPlaceholder': 'Auto-detect, or choose python.exe / python3',
|
||||
'settings.computerUse.pythonPathHint': 'Leave empty to auto-detect. Choose the Python executable from conda, pyenv, or another custom environment to prefer it for runtime setup.',
|
||||
'settings.computerUse.pythonPathBrowse': 'Browse',
|
||||
'settings.computerUse.pythonPathSave': 'Apply',
|
||||
'settings.computerUse.pythonPathAuto': 'Auto',
|
||||
'settings.computerUse.pythonPathSaved': 'Saved. Rechecks will prefer this interpreter.',
|
||||
'settings.computerUse.pythonPathSaveFailed': 'Save failed. Try again.',
|
||||
'settings.computerUse.pythonPathDialogTitle': 'Select Python Interpreter',
|
||||
'settings.computerUse.pythonPathDialogFailed': 'Could not open the file picker. Paste the path manually.',
|
||||
'settings.computerUse.venv': 'Virtual Environment',
|
||||
'settings.computerUse.venvReady': 'Ready',
|
||||
'settings.computerUse.venvNotReady': 'Not created',
|
||||
|
||||
@ -594,6 +594,17 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.computerUse.python': 'Python 3',
|
||||
'settings.computerUse.pythonNotFound': '未安装,请先安装 Python 3。',
|
||||
'settings.computerUse.pythonFound': '已安装',
|
||||
'settings.computerUse.pythonCustomInvalid': '自定义解释器不可用',
|
||||
'settings.computerUse.pythonPathLabel': 'Python 解释器路径',
|
||||
'settings.computerUse.pythonPathPlaceholder': '自动检测,或选择 python.exe / python3',
|
||||
'settings.computerUse.pythonPathHint': '留空则自动检测;选择 conda、pyenv 或自定义环境里的 Python 可执行文件后会优先使用它创建运行环境。',
|
||||
'settings.computerUse.pythonPathBrowse': '选择',
|
||||
'settings.computerUse.pythonPathSave': '应用',
|
||||
'settings.computerUse.pythonPathAuto': '自动',
|
||||
'settings.computerUse.pythonPathSaved': '已保存,重新检测会优先使用这个解释器。',
|
||||
'settings.computerUse.pythonPathSaveFailed': '保存失败,请稍后重试。',
|
||||
'settings.computerUse.pythonPathDialogTitle': '选择 Python 解释器',
|
||||
'settings.computerUse.pythonPathDialogFailed': '无法打开文件选择器,请手动粘贴路径。',
|
||||
'settings.computerUse.venv': '虚拟环境',
|
||||
'settings.computerUse.venvReady': '已就绪',
|
||||
'settings.computerUse.venvNotReady': '未创建',
|
||||
|
||||
@ -25,6 +25,8 @@ const readyStatus = {
|
||||
installed: true,
|
||||
version: '3.12.0',
|
||||
path: '/usr/bin/python3',
|
||||
source: 'system',
|
||||
error: null,
|
||||
},
|
||||
venv: {
|
||||
created: false,
|
||||
@ -48,6 +50,7 @@ const enabledConfig = {
|
||||
clipboardWrite: true,
|
||||
systemKeyCombos: true,
|
||||
},
|
||||
pythonPath: null,
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
@ -104,6 +107,25 @@ describe('ComputerUseSettings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('saves a custom Python interpreter path and rechecks status', async () => {
|
||||
render(<ComputerUseSettings />)
|
||||
|
||||
const input = await screen.findByLabelText('Python Interpreter Path')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, {
|
||||
target: { value: ' C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe ' },
|
||||
})
|
||||
fireEvent.click(screen.getByText('Apply'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
|
||||
pythonPath: 'C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe',
|
||||
})
|
||||
expect(computerUseApiMock.getStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the user-selected enablement when a stale refresh resolves later', async () => {
|
||||
const staleRefresh = deferred<typeof enabledConfig>()
|
||||
computerUseApiMock.getStatus.mockResolvedValue({
|
||||
|
||||
@ -61,6 +61,10 @@ export function ComputerUseSettings() {
|
||||
const [computerUseEnabled, setComputerUseEnabled] = useState(true)
|
||||
const [clipboardAccess, setClipboardAccess] = useState(true)
|
||||
const [systemKeys, setSystemKeys] = useState(true)
|
||||
const [pythonPathDraft, setPythonPathDraft] = useState('')
|
||||
const [pythonPathSaved, setPythonPathSaved] = useState('')
|
||||
const [pythonPathSaving, setPythonPathSaving] = useState(false)
|
||||
const [pythonPathMessage, setPythonPathMessage] = useState<string | null>(null)
|
||||
const configMutationSeqRef = useRef(0)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
@ -84,6 +88,8 @@ export function ComputerUseSettings() {
|
||||
setAuthorizedBundleIds(new Set(configResult.authorizedApps.map(a => a.bundleId)))
|
||||
setClipboardAccess(configResult.grantFlags.clipboardRead)
|
||||
setSystemKeys(configResult.grantFlags.systemKeyCombos)
|
||||
setPythonPathDraft(configResult.pythonPath ?? '')
|
||||
setPythonPathSaved(configResult.pythonPath ?? '')
|
||||
}, [])
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
@ -190,6 +196,42 @@ export function ComputerUseSettings() {
|
||||
})
|
||||
}
|
||||
|
||||
const savePythonPath = async (value = pythonPathDraft) => {
|
||||
configMutationSeqRef.current += 1
|
||||
const normalized = value.trim()
|
||||
setPythonPathSaving(true)
|
||||
setPythonPathMessage(null)
|
||||
try {
|
||||
await computerUseApi.setAuthorizedApps({ pythonPath: normalized || null })
|
||||
setPythonPathDraft(normalized)
|
||||
setPythonPathSaved(normalized)
|
||||
setPythonPathMessage(t('settings.computerUse.pythonPathSaved'))
|
||||
await fetchStatus()
|
||||
} catch {
|
||||
setPythonPathMessage(t('settings.computerUse.pythonPathSaveFailed'))
|
||||
} finally {
|
||||
setPythonPathSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const choosePythonPath = async () => {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
title: t('settings.computerUse.pythonPathDialogTitle'),
|
||||
})
|
||||
const selectedPath = Array.isArray(selected) ? selected[0] : selected
|
||||
if (typeof selectedPath === 'string' && selectedPath.trim()) {
|
||||
setPythonPathDraft(selectedPath)
|
||||
await savePythonPath(selectedPath)
|
||||
}
|
||||
} catch {
|
||||
setPythonPathMessage(t('settings.computerUse.pythonPathDialogFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const allReady =
|
||||
status?.supported &&
|
||||
status.python.installed &&
|
||||
@ -202,6 +244,12 @@ export function ComputerUseSettings() {
|
||||
const pythonDownloadUrl = status
|
||||
? PYTHON_DOWNLOAD_URLS[status.platform] ?? 'https://www.python.org/downloads/'
|
||||
: 'https://www.python.org/downloads/'
|
||||
const pythonPathDirty = pythonPathDraft.trim() !== pythonPathSaved
|
||||
const pythonDetail = status?.python.installed
|
||||
? `${t('settings.computerUse.pythonFound')} — ${status.python.version} (${status.python.path})`
|
||||
: status?.python.source === 'custom'
|
||||
? `${t('settings.computerUse.pythonCustomInvalid')} — ${status.python.path}${status.python.error ? `: ${status.python.error}` : ''}`
|
||||
: t('settings.computerUse.pythonNotFound')
|
||||
|
||||
// Filter apps by search query
|
||||
const filteredApps = useMemo(() => {
|
||||
@ -273,11 +321,7 @@ export function ComputerUseSettings() {
|
||||
<StatusRow
|
||||
label={t('settings.computerUse.python')}
|
||||
ok={status.python.installed}
|
||||
detail={
|
||||
status.python.installed
|
||||
? `${t('settings.computerUse.pythonFound')} — ${status.python.version} (${status.python.path})`
|
||||
: t('settings.computerUse.pythonNotFound')
|
||||
}
|
||||
detail={pythonDetail}
|
||||
/>
|
||||
<StatusRow
|
||||
label={t('settings.computerUse.venv')}
|
||||
@ -291,6 +335,54 @@ export function ComputerUseSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
|
||||
<label htmlFor="computer-use-python-path" className="block text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('settings.computerUse.pythonPathLabel')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
id="computer-use-python-path"
|
||||
type="text"
|
||||
value={pythonPathDraft}
|
||||
onChange={e => {
|
||||
setPythonPathDraft(e.target.value)
|
||||
setPythonPathMessage(null)
|
||||
}}
|
||||
placeholder={t('settings.computerUse.pythonPathPlaceholder')}
|
||||
className="min-w-[220px] flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2 font-mono text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-brand)] focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={choosePythonPath}
|
||||
disabled={pythonPathSaving}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-xs font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">folder_open</span>
|
||||
{t('settings.computerUse.pythonPathBrowse')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => savePythonPath()}
|
||||
disabled={pythonPathSaving || !pythonPathDirty}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-[var(--color-brand)] px-3 py-2 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">{pythonPathSaving ? 'hourglass_empty' : 'save'}</span>
|
||||
{t('settings.computerUse.pythonPathSave')}
|
||||
</button>
|
||||
{pythonPathSaved && (
|
||||
<button
|
||||
onClick={() => savePythonPath('')}
|
||||
disabled={pythonPathSaving}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-xs font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
|
||||
{t('settings.computerUse.pythonPathAuto')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{pythonPathMessage ?? t('settings.computerUse.pythonPathHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* macOS Permissions — only shown on macOS (darwin) */}
|
||||
{envReady && status.platform === 'darwin' && (
|
||||
<>
|
||||
|
||||
@ -65,4 +65,21 @@ describe('Computer Use API authorized app config', () => {
|
||||
)
|
||||
expect(JSON.parse(raw)).toMatchObject({ enabled: false })
|
||||
})
|
||||
|
||||
it('persists and normalizes a custom Python interpreter path', async () => {
|
||||
const pythonPath = ' C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe '
|
||||
const putRes = await callAuthorizedApps('PUT', { pythonPath })
|
||||
expect(putRes.status).toBe(200)
|
||||
|
||||
const getRes = await callAuthorizedApps('GET')
|
||||
expect(await getRes.json()).toMatchObject({
|
||||
pythonPath: 'C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe',
|
||||
})
|
||||
|
||||
const resetRes = await callAuthorizedApps('PUT', { pythonPath: '' })
|
||||
expect(resetRes.status).toBe(200)
|
||||
|
||||
const resetGetRes = await callAuthorizedApps('GET')
|
||||
expect(await resetGetRes.json()).toMatchObject({ pythonPath: null })
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,9 +24,62 @@ describe('detectPythonRuntime', () => {
|
||||
expect(result.command).toBe('python3')
|
||||
expect(result.prefixArgs).toEqual([])
|
||||
expect(result.source).toBe('system')
|
||||
expect(result.error).toBeNull()
|
||||
expect(calls).toEqual(['python3 --version', 'where python3'])
|
||||
})
|
||||
|
||||
test('uses a custom Python path before PATH candidates', async () => {
|
||||
const calls: string[] = []
|
||||
const customPython = 'C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe'
|
||||
const result = await detectPythonRuntime(
|
||||
'win32',
|
||||
async (cmd, args) => {
|
||||
calls.push(`${cmd} ${args.join(' ')}`.trim())
|
||||
if (cmd === customPython && args.join(' ') === '--version') {
|
||||
return { ok: true, stdout: 'Python 3.11.9', stderr: '', code: 0 }
|
||||
}
|
||||
return { ok: false, stdout: '', stderr: '', code: 1 }
|
||||
},
|
||||
undefined,
|
||||
customPython,
|
||||
)
|
||||
|
||||
expect(result.installed).toBe(true)
|
||||
expect(result.version).toBe('3.11.9')
|
||||
expect(result.path).toBe(customPython)
|
||||
expect(result.command).toBe(customPython)
|
||||
expect(result.prefixArgs).toEqual([])
|
||||
expect(result.source).toBe('custom')
|
||||
expect(result.error).toBeNull()
|
||||
expect(calls).toEqual([`${customPython} --version`])
|
||||
})
|
||||
|
||||
test('reports an invalid custom Python path without falling back to PATH', async () => {
|
||||
const calls: string[] = []
|
||||
const result = await detectPythonRuntime(
|
||||
'win32',
|
||||
async (cmd, args) => {
|
||||
calls.push(`${cmd} ${args.join(' ')}`.trim())
|
||||
if (cmd === 'C:\\missing\\python.exe') {
|
||||
return { ok: false, stdout: '', stderr: 'not found', code: 1 }
|
||||
}
|
||||
if (cmd === 'python') {
|
||||
return { ok: true, stdout: 'Python 3.12.0', stderr: '', code: 0 }
|
||||
}
|
||||
return { ok: false, stdout: '', stderr: '', code: 1 }
|
||||
},
|
||||
undefined,
|
||||
'C:\\missing\\python.exe',
|
||||
)
|
||||
|
||||
expect(result.installed).toBe(false)
|
||||
expect(result.path).toBe('C:\\missing\\python.exe')
|
||||
expect(result.command).toBe('C:\\missing\\python.exe')
|
||||
expect(result.source).toBe('custom')
|
||||
expect(result.error).toBe('not found')
|
||||
expect(calls).toEqual(['C:\\missing\\python.exe --version'])
|
||||
})
|
||||
|
||||
test('falls back to py -3 on Windows', async () => {
|
||||
const result = await detectPythonRuntime(
|
||||
'win32',
|
||||
@ -50,6 +103,7 @@ describe('detectPythonRuntime', () => {
|
||||
expect(result.command).toBe('py')
|
||||
expect(result.prefixArgs).toEqual(['-3'])
|
||||
expect(result.source).toBe('system')
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
test('falls back to venv python when system python is not discoverable', async () => {
|
||||
@ -71,5 +125,6 @@ describe('detectPythonRuntime', () => {
|
||||
expect(result.command).toBe(venvPython)
|
||||
expect(result.prefixArgs).toEqual([])
|
||||
expect(result.source).toBe('venv')
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -25,7 +25,8 @@ export type PythonRuntimeResolution = {
|
||||
path: string | null
|
||||
command: string | null
|
||||
prefixArgs: string[]
|
||||
source: 'system' | 'venv' | null
|
||||
source: 'custom' | 'system' | 'venv' | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function getPythonCandidates(platform: NodeJS.Platform): PythonCandidate[] {
|
||||
@ -90,7 +91,34 @@ export async function detectPythonRuntime(
|
||||
platform: NodeJS.Platform,
|
||||
runCommand: CommandRunner,
|
||||
venvPythonPath?: string,
|
||||
customPythonPath?: string | null,
|
||||
): Promise<PythonRuntimeResolution> {
|
||||
const normalizedCustomPath = customPythonPath?.trim()
|
||||
if (normalizedCustomPath) {
|
||||
const customResult = await runCommand(normalizedCustomPath, ['--version'])
|
||||
if (customResult.ok) {
|
||||
return {
|
||||
installed: true,
|
||||
version: extractPythonVersion(`${customResult.stdout}\n${customResult.stderr}`),
|
||||
path: normalizedCustomPath,
|
||||
command: normalizedCustomPath,
|
||||
prefixArgs: [],
|
||||
source: 'custom',
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: normalizedCustomPath,
|
||||
command: normalizedCustomPath,
|
||||
prefixArgs: [],
|
||||
source: 'custom',
|
||||
error: customResult.stderr || customResult.stdout || `Failed to run ${normalizedCustomPath}`,
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of getPythonCandidates(platform)) {
|
||||
const versionResult = await runCommand(candidate.command, [...candidate.prefixArgs, '--version'])
|
||||
if (!versionResult.ok) continue
|
||||
@ -102,6 +130,7 @@ export async function detectPythonRuntime(
|
||||
command: candidate.command,
|
||||
prefixArgs: candidate.prefixArgs,
|
||||
source: 'system',
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,6 +144,7 @@ export async function detectPythonRuntime(
|
||||
command: venvPythonPath,
|
||||
prefixArgs: [],
|
||||
source: 'venv',
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -126,6 +156,6 @@ export async function detectPythonRuntime(
|
||||
command: null,
|
||||
prefixArgs: [],
|
||||
source: null,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ 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
|
||||
@ -122,6 +123,8 @@ type EnvStatus = {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
source: 'custom' | 'system' | 'venv' | null
|
||||
error: string | null
|
||||
}
|
||||
venv: {
|
||||
created: boolean
|
||||
@ -146,8 +149,14 @@ async function checkStatus(): Promise<EnvStatus> {
|
||||
? 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)
|
||||
const pythonRuntime = await detectPythonRuntime(
|
||||
platform,
|
||||
runCommand,
|
||||
venvCreated ? venvPython : undefined,
|
||||
config.pythonPath,
|
||||
)
|
||||
|
||||
// Check dependencies — use the state dir copy
|
||||
const reqPath = getRequirementsPath()
|
||||
@ -193,6 +202,8 @@ async function checkStatus(): Promise<EnvStatus> {
|
||||
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 },
|
||||
@ -212,27 +223,33 @@ async function runSetup(): Promise<SetupResult> {
|
||||
? 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: 'Python 3 未安装,请先安装 Python 3',
|
||||
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 === 'venv'
|
||||
? `Python ${pythonRuntime.version}(使用现有虚拟环境)`
|
||||
: `Python ${pythonRuntime.version}`,
|
||||
message: pythonRuntime.source === 'custom'
|
||||
? `Python ${pythonRuntime.version}(使用自定义解释器)`
|
||||
: pythonRuntime.source === 'venv'
|
||||
? `Python ${pythonRuntime.version}(使用现有虚拟环境)`
|
||||
: `Python ${pythonRuntime.version}`,
|
||||
})
|
||||
|
||||
// Step 2: Extract runtime files to ~/.claude/.runtime/
|
||||
@ -356,6 +373,7 @@ type ComputerUseConfig = {
|
||||
clipboardWrite: boolean
|
||||
systemKeyCombos: boolean
|
||||
}
|
||||
pythonPath: string | null
|
||||
}
|
||||
|
||||
type RequestAccessBody = {
|
||||
@ -367,6 +385,7 @@ const DEFAULT_CONFIG: ComputerUseConfig = {
|
||||
enabled: true,
|
||||
authorizedApps: [],
|
||||
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||
pythonPath: null,
|
||||
}
|
||||
|
||||
async function loadConfig(): Promise<ComputerUseConfig> {
|
||||
@ -439,6 +458,7 @@ export async function handleComputerUseApi(
|
||||
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 {
|
||||
|
||||
@ -10,6 +10,7 @@ describe('resolveStoredComputerUseConfig', () => {
|
||||
enabled: true,
|
||||
authorizedApps: [],
|
||||
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||
pythonPath: null,
|
||||
})
|
||||
})
|
||||
|
||||
@ -35,6 +36,20 @@ describe('resolveStoredComputerUseConfig', () => {
|
||||
clipboardWrite: true,
|
||||
systemKeyCombos: true,
|
||||
},
|
||||
pythonPath: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizes a stored custom Python interpreter path', () => {
|
||||
expect(
|
||||
resolveStoredComputerUseConfig({
|
||||
pythonPath: ' C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe ',
|
||||
}),
|
||||
).toMatchObject({
|
||||
pythonPath: 'C:\\Users\\me\\miniconda3\\envs\\cu\\python.exe',
|
||||
})
|
||||
expect(resolveStoredComputerUseConfig({ pythonPath: '' })).toMatchObject({
|
||||
pythonPath: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -13,6 +13,7 @@ export type StoredComputerUseConfig = {
|
||||
enabled?: boolean
|
||||
authorizedApps?: StoredAuthorizedApp[]
|
||||
grantFlags?: Partial<CuGrantFlags>
|
||||
pythonPath?: string | null
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPUTER_USE_ENABLED = true
|
||||
@ -37,6 +38,7 @@ export function resolveStoredComputerUseConfig(
|
||||
enabled: boolean
|
||||
authorizedApps: StoredAuthorizedApp[]
|
||||
grantFlags: CuGrantFlags
|
||||
pythonPath: string | null
|
||||
} {
|
||||
return {
|
||||
enabled: config?.enabled ?? DEFAULT_COMPUTER_USE_ENABLED,
|
||||
@ -45,9 +47,16 @@ export function resolveStoredComputerUseConfig(
|
||||
...DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||
...(config?.grantFlags ?? {}),
|
||||
},
|
||||
pythonPath: normalizePythonPath(config?.pythonPath),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePythonPath(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
export async function loadStoredComputerUseConfig(): Promise<
|
||||
ReturnType<typeof resolveStoredComputerUseConfig>
|
||||
> {
|
||||
|
||||
@ -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 { loadStoredComputerUseConfig } from './preauthorizedConfig.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(__dirname, '../../..')
|
||||
@ -60,6 +61,12 @@ async function runOrThrow(file: string, args: string[], label: string): Promise<
|
||||
return stdout
|
||||
}
|
||||
|
||||
async function getVenvCreationPythonCommand(): Promise<string> {
|
||||
const config = await loadStoredComputerUseConfig()
|
||||
if (config.pythonPath) return config.pythonPath
|
||||
return isWindows ? 'python' : 'python3'
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure runtime source files exist in ~/.claude/.runtime/.
|
||||
* In dev mode, copies from the project's runtime/ directory on first run.
|
||||
@ -92,7 +99,7 @@ export async function ensureBootstrapped(): Promise<void> {
|
||||
|
||||
if (!(await pathExists(pythonBinPath()))) {
|
||||
logForDebugging('creating runtime venv at %s', { level: 'debug' })
|
||||
const pythonCmd = isWindows ? 'python' : 'python3'
|
||||
const pythonCmd = await getVenvCreationPythonCommand()
|
||||
await runOrThrow(pythonCmd, ['-m', 'venv', venvRoot], 'python venv creation')
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user