mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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
|
installed: boolean
|
||||||
version: string | null
|
version: string | null
|
||||||
path: string | null
|
path: string | null
|
||||||
|
source: 'custom' | 'system' | 'venv' | null
|
||||||
|
error: string | null
|
||||||
}
|
}
|
||||||
venv: {
|
venv: {
|
||||||
created: boolean
|
created: boolean
|
||||||
@ -53,6 +55,7 @@ export type ComputerUseConfig = {
|
|||||||
clipboardWrite: boolean
|
clipboardWrite: boolean
|
||||||
systemKeyCombos: boolean
|
systemKeyCombos: boolean
|
||||||
}
|
}
|
||||||
|
pythonPath: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const computerUseApi = {
|
export const computerUseApi = {
|
||||||
|
|||||||
@ -592,6 +592,17 @@ export const en = {
|
|||||||
'settings.computerUse.python': 'Python 3',
|
'settings.computerUse.python': 'Python 3',
|
||||||
'settings.computerUse.pythonNotFound': 'Not installed. Please install Python 3 first.',
|
'settings.computerUse.pythonNotFound': 'Not installed. Please install Python 3 first.',
|
||||||
'settings.computerUse.pythonFound': 'Installed',
|
'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.venv': 'Virtual Environment',
|
||||||
'settings.computerUse.venvReady': 'Ready',
|
'settings.computerUse.venvReady': 'Ready',
|
||||||
'settings.computerUse.venvNotReady': 'Not created',
|
'settings.computerUse.venvNotReady': 'Not created',
|
||||||
|
|||||||
@ -594,6 +594,17 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.computerUse.python': 'Python 3',
|
'settings.computerUse.python': 'Python 3',
|
||||||
'settings.computerUse.pythonNotFound': '未安装,请先安装 Python 3。',
|
'settings.computerUse.pythonNotFound': '未安装,请先安装 Python 3。',
|
||||||
'settings.computerUse.pythonFound': '已安装',
|
'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.venv': '虚拟环境',
|
||||||
'settings.computerUse.venvReady': '已就绪',
|
'settings.computerUse.venvReady': '已就绪',
|
||||||
'settings.computerUse.venvNotReady': '未创建',
|
'settings.computerUse.venvNotReady': '未创建',
|
||||||
|
|||||||
@ -25,6 +25,8 @@ const readyStatus = {
|
|||||||
installed: true,
|
installed: true,
|
||||||
version: '3.12.0',
|
version: '3.12.0',
|
||||||
path: '/usr/bin/python3',
|
path: '/usr/bin/python3',
|
||||||
|
source: 'system',
|
||||||
|
error: null,
|
||||||
},
|
},
|
||||||
venv: {
|
venv: {
|
||||||
created: false,
|
created: false,
|
||||||
@ -48,6 +50,7 @@ const enabledConfig = {
|
|||||||
clipboardWrite: true,
|
clipboardWrite: true,
|
||||||
systemKeyCombos: true,
|
systemKeyCombos: true,
|
||||||
},
|
},
|
||||||
|
pythonPath: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
function deferred<T>() {
|
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 () => {
|
it('keeps the user-selected enablement when a stale refresh resolves later', async () => {
|
||||||
const staleRefresh = deferred<typeof enabledConfig>()
|
const staleRefresh = deferred<typeof enabledConfig>()
|
||||||
computerUseApiMock.getStatus.mockResolvedValue({
|
computerUseApiMock.getStatus.mockResolvedValue({
|
||||||
|
|||||||
@ -61,6 +61,10 @@ export function ComputerUseSettings() {
|
|||||||
const [computerUseEnabled, setComputerUseEnabled] = useState(true)
|
const [computerUseEnabled, setComputerUseEnabled] = useState(true)
|
||||||
const [clipboardAccess, setClipboardAccess] = useState(true)
|
const [clipboardAccess, setClipboardAccess] = useState(true)
|
||||||
const [systemKeys, setSystemKeys] = 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 configMutationSeqRef = useRef(0)
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
const fetchStatus = useCallback(async () => {
|
||||||
@ -84,6 +88,8 @@ export function ComputerUseSettings() {
|
|||||||
setAuthorizedBundleIds(new Set(configResult.authorizedApps.map(a => a.bundleId)))
|
setAuthorizedBundleIds(new Set(configResult.authorizedApps.map(a => a.bundleId)))
|
||||||
setClipboardAccess(configResult.grantFlags.clipboardRead)
|
setClipboardAccess(configResult.grantFlags.clipboardRead)
|
||||||
setSystemKeys(configResult.grantFlags.systemKeyCombos)
|
setSystemKeys(configResult.grantFlags.systemKeyCombos)
|
||||||
|
setPythonPathDraft(configResult.pythonPath ?? '')
|
||||||
|
setPythonPathSaved(configResult.pythonPath ?? '')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const fetchConfig = useCallback(async () => {
|
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 =
|
const allReady =
|
||||||
status?.supported &&
|
status?.supported &&
|
||||||
status.python.installed &&
|
status.python.installed &&
|
||||||
@ -202,6 +244,12 @@ export function ComputerUseSettings() {
|
|||||||
const pythonDownloadUrl = status
|
const pythonDownloadUrl = status
|
||||||
? PYTHON_DOWNLOAD_URLS[status.platform] ?? 'https://www.python.org/downloads/'
|
? PYTHON_DOWNLOAD_URLS[status.platform] ?? 'https://www.python.org/downloads/'
|
||||||
: '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
|
// Filter apps by search query
|
||||||
const filteredApps = useMemo(() => {
|
const filteredApps = useMemo(() => {
|
||||||
@ -273,11 +321,7 @@ export function ComputerUseSettings() {
|
|||||||
<StatusRow
|
<StatusRow
|
||||||
label={t('settings.computerUse.python')}
|
label={t('settings.computerUse.python')}
|
||||||
ok={status.python.installed}
|
ok={status.python.installed}
|
||||||
detail={
|
detail={pythonDetail}
|
||||||
status.python.installed
|
|
||||||
? `${t('settings.computerUse.pythonFound')} — ${status.python.version} (${status.python.path})`
|
|
||||||
: t('settings.computerUse.pythonNotFound')
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label={t('settings.computerUse.venv')}
|
label={t('settings.computerUse.venv')}
|
||||||
@ -291,6 +335,54 @@ export function ComputerUseSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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) */}
|
{/* macOS Permissions — only shown on macOS (darwin) */}
|
||||||
{envReady && status.platform === 'darwin' && (
|
{envReady && status.platform === 'darwin' && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -65,4 +65,21 @@ describe('Computer Use API authorized app config', () => {
|
|||||||
)
|
)
|
||||||
expect(JSON.parse(raw)).toMatchObject({ enabled: false })
|
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.command).toBe('python3')
|
||||||
expect(result.prefixArgs).toEqual([])
|
expect(result.prefixArgs).toEqual([])
|
||||||
expect(result.source).toBe('system')
|
expect(result.source).toBe('system')
|
||||||
|
expect(result.error).toBeNull()
|
||||||
expect(calls).toEqual(['python3 --version', 'where python3'])
|
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 () => {
|
test('falls back to py -3 on Windows', async () => {
|
||||||
const result = await detectPythonRuntime(
|
const result = await detectPythonRuntime(
|
||||||
'win32',
|
'win32',
|
||||||
@ -50,6 +103,7 @@ describe('detectPythonRuntime', () => {
|
|||||||
expect(result.command).toBe('py')
|
expect(result.command).toBe('py')
|
||||||
expect(result.prefixArgs).toEqual(['-3'])
|
expect(result.prefixArgs).toEqual(['-3'])
|
||||||
expect(result.source).toBe('system')
|
expect(result.source).toBe('system')
|
||||||
|
expect(result.error).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('falls back to venv python when system python is not discoverable', async () => {
|
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.command).toBe(venvPython)
|
||||||
expect(result.prefixArgs).toEqual([])
|
expect(result.prefixArgs).toEqual([])
|
||||||
expect(result.source).toBe('venv')
|
expect(result.source).toBe('venv')
|
||||||
|
expect(result.error).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -25,7 +25,8 @@ export type PythonRuntimeResolution = {
|
|||||||
path: string | null
|
path: string | null
|
||||||
command: string | null
|
command: string | null
|
||||||
prefixArgs: string[]
|
prefixArgs: string[]
|
||||||
source: 'system' | 'venv' | null
|
source: 'custom' | 'system' | 'venv' | null
|
||||||
|
error: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPythonCandidates(platform: NodeJS.Platform): PythonCandidate[] {
|
function getPythonCandidates(platform: NodeJS.Platform): PythonCandidate[] {
|
||||||
@ -90,7 +91,34 @@ export async function detectPythonRuntime(
|
|||||||
platform: NodeJS.Platform,
|
platform: NodeJS.Platform,
|
||||||
runCommand: CommandRunner,
|
runCommand: CommandRunner,
|
||||||
venvPythonPath?: string,
|
venvPythonPath?: string,
|
||||||
|
customPythonPath?: string | null,
|
||||||
): Promise<PythonRuntimeResolution> {
|
): 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)) {
|
for (const candidate of getPythonCandidates(platform)) {
|
||||||
const versionResult = await runCommand(candidate.command, [...candidate.prefixArgs, '--version'])
|
const versionResult = await runCommand(candidate.command, [...candidate.prefixArgs, '--version'])
|
||||||
if (!versionResult.ok) continue
|
if (!versionResult.ok) continue
|
||||||
@ -102,6 +130,7 @@ export async function detectPythonRuntime(
|
|||||||
command: candidate.command,
|
command: candidate.command,
|
||||||
prefixArgs: candidate.prefixArgs,
|
prefixArgs: candidate.prefixArgs,
|
||||||
source: 'system',
|
source: 'system',
|
||||||
|
error: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,6 +144,7 @@ export async function detectPythonRuntime(
|
|||||||
command: venvPythonPath,
|
command: venvPythonPath,
|
||||||
prefixArgs: [],
|
prefixArgs: [],
|
||||||
source: 'venv',
|
source: 'venv',
|
||||||
|
error: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,6 +156,6 @@ export async function detectPythonRuntime(
|
|||||||
command: null,
|
command: null,
|
||||||
prefixArgs: [],
|
prefixArgs: [],
|
||||||
source: null,
|
source: null,
|
||||||
|
error: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { detectPythonRuntime } from './computer-use-python.js'
|
|||||||
import {
|
import {
|
||||||
DEFAULT_DESKTOP_GRANT_FLAGS,
|
DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||||
loadStoredComputerUseConfig,
|
loadStoredComputerUseConfig,
|
||||||
|
normalizePythonPath,
|
||||||
saveStoredComputerUseConfig,
|
saveStoredComputerUseConfig,
|
||||||
} from '../../utils/computerUse/preauthorizedConfig.js'
|
} from '../../utils/computerUse/preauthorizedConfig.js'
|
||||||
// Embed helper scripts at compile time so they're available in bundled mode
|
// Embed helper scripts at compile time so they're available in bundled mode
|
||||||
@ -122,6 +123,8 @@ type EnvStatus = {
|
|||||||
installed: boolean
|
installed: boolean
|
||||||
version: string | null
|
version: string | null
|
||||||
path: string | null
|
path: string | null
|
||||||
|
source: 'custom' | 'system' | 'venv' | null
|
||||||
|
error: string | null
|
||||||
}
|
}
|
||||||
venv: {
|
venv: {
|
||||||
created: boolean
|
created: boolean
|
||||||
@ -146,8 +149,14 @@ async function checkStatus(): Promise<EnvStatus> {
|
|||||||
? join(venvRoot, 'Scripts', 'python.exe')
|
? join(venvRoot, 'Scripts', 'python.exe')
|
||||||
: join(venvRoot, 'bin', 'python3')
|
: join(venvRoot, 'bin', 'python3')
|
||||||
const venvCreated = await pathExists(venvPython)
|
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
|
// Check dependencies — use the state dir copy
|
||||||
const reqPath = getRequirementsPath()
|
const reqPath = getRequirementsPath()
|
||||||
@ -193,6 +202,8 @@ async function checkStatus(): Promise<EnvStatus> {
|
|||||||
installed: pythonRuntime.installed,
|
installed: pythonRuntime.installed,
|
||||||
version: pythonRuntime.version,
|
version: pythonRuntime.version,
|
||||||
path: pythonRuntime.path,
|
path: pythonRuntime.path,
|
||||||
|
source: pythonRuntime.source,
|
||||||
|
error: pythonRuntime.error,
|
||||||
},
|
},
|
||||||
venv: { created: venvCreated, path: venvRoot },
|
venv: { created: venvCreated, path: venvRoot },
|
||||||
dependencies: { installed: depsInstalled, requirementsFound: requirementsFound || true },
|
dependencies: { installed: depsInstalled, requirementsFound: requirementsFound || true },
|
||||||
@ -212,27 +223,33 @@ async function runSetup(): Promise<SetupResult> {
|
|||||||
? join(venvRoot, 'Scripts', 'python.exe')
|
? join(venvRoot, 'Scripts', 'python.exe')
|
||||||
: join(venvRoot, 'bin', 'python3')
|
: join(venvRoot, 'bin', 'python3')
|
||||||
const venvExists = await pathExists(venvPython)
|
const venvExists = await pathExists(venvPython)
|
||||||
|
const config = await loadConfig()
|
||||||
|
|
||||||
// Step 1: Check python
|
// Step 1: Check python
|
||||||
const pythonRuntime = await detectPythonRuntime(
|
const pythonRuntime = await detectPythonRuntime(
|
||||||
process.platform,
|
process.platform,
|
||||||
runCommand,
|
runCommand,
|
||||||
venvExists ? venvPython : undefined,
|
venvExists ? venvPython : undefined,
|
||||||
|
config.pythonPath,
|
||||||
)
|
)
|
||||||
if (!pythonRuntime.installed) {
|
if (!pythonRuntime.installed) {
|
||||||
steps.push({
|
steps.push({
|
||||||
name: 'python_check',
|
name: 'python_check',
|
||||||
ok: false,
|
ok: false,
|
||||||
message: 'Python 3 未安装,请先安装 Python 3',
|
message: pythonRuntime.source === 'custom'
|
||||||
|
? `自定义 Python 路径不可用: ${pythonRuntime.error ?? pythonRuntime.path}`
|
||||||
|
: 'Python 3 未安装,请先安装 Python 3',
|
||||||
})
|
})
|
||||||
return { success: false, steps }
|
return { success: false, steps }
|
||||||
}
|
}
|
||||||
steps.push({
|
steps.push({
|
||||||
name: 'python_check',
|
name: 'python_check',
|
||||||
ok: true,
|
ok: true,
|
||||||
message: pythonRuntime.source === 'venv'
|
message: pythonRuntime.source === 'custom'
|
||||||
? `Python ${pythonRuntime.version}(使用现有虚拟环境)`
|
? `Python ${pythonRuntime.version}(使用自定义解释器)`
|
||||||
: `Python ${pythonRuntime.version}`,
|
: pythonRuntime.source === 'venv'
|
||||||
|
? `Python ${pythonRuntime.version}(使用现有虚拟环境)`
|
||||||
|
: `Python ${pythonRuntime.version}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 2: Extract runtime files to ~/.claude/.runtime/
|
// Step 2: Extract runtime files to ~/.claude/.runtime/
|
||||||
@ -356,6 +373,7 @@ type ComputerUseConfig = {
|
|||||||
clipboardWrite: boolean
|
clipboardWrite: boolean
|
||||||
systemKeyCombos: boolean
|
systemKeyCombos: boolean
|
||||||
}
|
}
|
||||||
|
pythonPath: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestAccessBody = {
|
type RequestAccessBody = {
|
||||||
@ -367,6 +385,7 @@ const DEFAULT_CONFIG: ComputerUseConfig = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
authorizedApps: [],
|
authorizedApps: [],
|
||||||
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
|
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||||
|
pythonPath: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfig(): Promise<ComputerUseConfig> {
|
async function loadConfig(): Promise<ComputerUseConfig> {
|
||||||
@ -439,6 +458,7 @@ export async function handleComputerUseApi(
|
|||||||
if (body.enabled !== undefined) config.enabled = body.enabled
|
if (body.enabled !== undefined) config.enabled = body.enabled
|
||||||
if (body.authorizedApps) config.authorizedApps = body.authorizedApps
|
if (body.authorizedApps) config.authorizedApps = body.authorizedApps
|
||||||
if (body.grantFlags) config.grantFlags = { ...config.grantFlags, ...body.grantFlags }
|
if (body.grantFlags) config.grantFlags = { ...config.grantFlags, ...body.grantFlags }
|
||||||
|
if ('pythonPath' in body) config.pythonPath = normalizePythonPath(body.pythonPath)
|
||||||
await saveConfig(config)
|
await saveConfig(config)
|
||||||
return Response.json({ ok: true })
|
return Response.json({ ok: true })
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ describe('resolveStoredComputerUseConfig', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
authorizedApps: [],
|
authorizedApps: [],
|
||||||
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
|
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||||
|
pythonPath: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -35,6 +36,20 @@ describe('resolveStoredComputerUseConfig', () => {
|
|||||||
clipboardWrite: true,
|
clipboardWrite: true,
|
||||||
systemKeyCombos: 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
|
enabled?: boolean
|
||||||
authorizedApps?: StoredAuthorizedApp[]
|
authorizedApps?: StoredAuthorizedApp[]
|
||||||
grantFlags?: Partial<CuGrantFlags>
|
grantFlags?: Partial<CuGrantFlags>
|
||||||
|
pythonPath?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_COMPUTER_USE_ENABLED = true
|
export const DEFAULT_COMPUTER_USE_ENABLED = true
|
||||||
@ -37,6 +38,7 @@ export function resolveStoredComputerUseConfig(
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
authorizedApps: StoredAuthorizedApp[]
|
authorizedApps: StoredAuthorizedApp[]
|
||||||
grantFlags: CuGrantFlags
|
grantFlags: CuGrantFlags
|
||||||
|
pythonPath: string | null
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
enabled: config?.enabled ?? DEFAULT_COMPUTER_USE_ENABLED,
|
enabled: config?.enabled ?? DEFAULT_COMPUTER_USE_ENABLED,
|
||||||
@ -45,9 +47,16 @@ export function resolveStoredComputerUseConfig(
|
|||||||
...DEFAULT_DESKTOP_GRANT_FLAGS,
|
...DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||||
...(config?.grantFlags ?? {}),
|
...(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<
|
export async function loadStoredComputerUseConfig(): Promise<
|
||||||
ReturnType<typeof resolveStoredComputerUseConfig>
|
ReturnType<typeof resolveStoredComputerUseConfig>
|
||||||
> {
|
> {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { execFileNoThrow } from '../execFileNoThrow.js'
|
import { execFileNoThrow } from '../execFileNoThrow.js'
|
||||||
import { logForDebugging } from '../debug.js'
|
import { logForDebugging } from '../debug.js'
|
||||||
import { getClaudeConfigHomeDir } from '../envUtils.js'
|
import { getClaudeConfigHomeDir } from '../envUtils.js'
|
||||||
|
import { loadStoredComputerUseConfig } from './preauthorizedConfig.js'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const projectRoot = path.resolve(__dirname, '../../..')
|
const projectRoot = path.resolve(__dirname, '../../..')
|
||||||
@ -60,6 +61,12 @@ async function runOrThrow(file: string, args: string[], label: string): Promise<
|
|||||||
return stdout
|
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/.
|
* Ensure runtime source files exist in ~/.claude/.runtime/.
|
||||||
* In dev mode, copies from the project's runtime/ directory on first run.
|
* 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()))) {
|
if (!(await pathExists(pythonBinPath()))) {
|
||||||
logForDebugging('creating runtime venv at %s', { level: 'debug' })
|
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')
|
await runOrThrow(pythonCmd, ['-m', 'venv', venvRoot], 'python venv creation')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user