/** * Python runtime resolution utilities. * * Centralizes environment filtering, venv detection, and Python executable resolution * for both the shared gateway and local kernel spawning. */ import * as fs from "node:fs"; import * as path from "node:path"; import { $env, $which, getPythonEnvDir } from "@oh-my-pi/pi-utils"; const DEFAULT_ENV_ALLOWLIST = new Set([ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "LC_MESSAGES", "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "TMPDIR", "TEMP", "TMP", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "SSH_AUTH_SOCK", "SSH_AGENT_PID", "CONDA_PREFIX", "CONDA_DEFAULT_ENV", "VIRTUAL_ENV", "PYTHONPATH", "LD_LIBRARY_PATH", ]); const WINDOWS_ENV_ALLOWLIST = new Set([ "APPDATA", "COMPUTERNAME", "COMSPEC", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "NUMBER_OF_PROCESSORS", "OS", "PATH", "PATHEXT", "PROCESSOR_ARCHITECTURE", "PROCESSOR_IDENTIFIER", "PROGRAMDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432", "SESSIONNAME", "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "TMP", "USERDOMAIN", "USERDOMAIN_ROAMINGPROFILE", "USERPROFILE", "USERNAME", "WINDIR", ]); const DEFAULT_ENV_DENYLIST = new Set([ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "PERPLEXITY_API_KEY", "PERPLEXITY_COOKIES", "EXA_API_KEY", "AZURE_OPENAI_API_KEY", "MISTRAL_API_KEY", ]); const DEFAULT_ENV_ALLOW_PREFIXES = ["LC_", "XDG_", "PI_"]; const CASE_INSENSITIVE_ENV = process.platform === "win32"; const BASE_ENV_ALLOWLIST = new Set([...DEFAULT_ENV_ALLOWLIST, ...WINDOWS_ENV_ALLOWLIST]); const NORMALIZED_ALLOWLIST = new Set( Array.from(BASE_ENV_ALLOWLIST, key => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)), ); const NORMALIZED_DENYLIST = new Set( Array.from(DEFAULT_ENV_DENYLIST, key => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)), ); const NORMALIZED_ALLOW_PREFIXES = CASE_INSENSITIVE_ENV ? DEFAULT_ENV_ALLOW_PREFIXES.map(prefix => prefix.toUpperCase()) : DEFAULT_ENV_ALLOW_PREFIXES; function normalizeEnvKey(key: string): string { return CASE_INSENSITIVE_ENV ? key.toUpperCase() : key; } function resolvePathKey(env: Record): string { if (!CASE_INSENSITIVE_ENV) return "PATH"; const match = Object.keys(env).find(candidate => candidate.toLowerCase() === "path"); return match ?? "PATH"; } function resolveManagedPythonEnv(): string { return getPythonEnvDir(); } function resolveManagedPythonCandidate(): { venvPath: string; pythonPath: string } { const venvPath = resolveManagedPythonEnv(); const binDir = process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin"); const pythonPath = path.join(binDir, process.platform === "win32" ? "python.exe" : "python"); return { venvPath, pythonPath }; } export interface PythonRuntime { /** Path to python executable */ pythonPath: string; /** Filtered environment variables */ env: Record; /** Path to virtual environment, if detected */ venvPath?: string; } /** * Filter environment variables to a safe allowlist for Python subprocesses. * Removes sensitive API keys and limits to known-safe variables. */ export function filterEnv(env: Record): Record { const filtered: Record = {}; for (const [key, value] of Object.entries(env)) { if (value === undefined) continue; const normalizedKey = normalizeEnvKey(key); if (NORMALIZED_DENYLIST.has(normalizedKey)) continue; if (NORMALIZED_ALLOWLIST.has(normalizedKey)) { const destKey = normalizedKey === "PATH" ? "PATH" : key; filtered[destKey] = value; continue; } if (NORMALIZED_ALLOW_PREFIXES.some(prefix => normalizedKey.startsWith(prefix))) { filtered[key] = value; } } return filtered; } /** * Detect virtual environment path from VIRTUAL_ENV or common locations. */ export function resolveVenvPath(cwd: string): string | undefined { if ($env.VIRTUAL_ENV) return $env.VIRTUAL_ENV; if ($env.CONDA_PREFIX) return $env.CONDA_PREFIX; const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } return undefined; } /** * Resolve Python runtime including executable path, environment, and venv detection. */ export function resolvePythonRuntime(cwd: string, baseEnv: Record): PythonRuntime { const env = { ...baseEnv }; const venvPath = env.VIRTUAL_ENV ?? resolveVenvPath(cwd); if (venvPath) { env.VIRTUAL_ENV = venvPath; const binDir = process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin"); const pythonCandidate = path.join(binDir, process.platform === "win32" ? "python.exe" : "python"); if (fs.existsSync(pythonCandidate)) { const pathKey = resolvePathKey(env); const currentPath = env[pathKey]; env[pathKey] = currentPath ? `${binDir}${path.delimiter}${currentPath}` : binDir; return { pythonPath: pythonCandidate, env, venvPath, }; } } const managed = resolveManagedPythonCandidate(); if (fs.existsSync(managed.pythonPath)) { env.VIRTUAL_ENV = managed.venvPath; const pathKey = resolvePathKey(env); const currentPath = env[pathKey]; const managedBin = process.platform === "win32" ? path.join(managed.venvPath, "Scripts") : path.join(managed.venvPath, "bin"); env[pathKey] = currentPath ? `${managedBin}${path.delimiter}${currentPath}` : managedBin; return { pythonPath: managed.pythonPath, env, venvPath: managed.venvPath, }; } const pythonPath = $which("python") ?? $which("python3"); if (!pythonPath) { throw new Error("Python executable not found on PATH"); } return { pythonPath, env, }; }