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:
程序员阿江(Relakkes) 2026-05-09 21:46:11 +08:00
parent 3193e741c7
commit b31be5161c
12 changed files with 305 additions and 13 deletions

View File

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

View File

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

View File

@ -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': '未创建',

View File

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

View File

@ -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' && (
<>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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