Files
oh-my-pi/packages/coding-agent/src/eval/py/runtime.ts
T
can1357 06dc3976f0 fix(coding-agent): probed all Python runtimes to bypass broken managed env
- Replaced single-candidate resolution with `enumeratePythonRuntimes`, returning venv, managed env, and system interpreter in priority order.
- Availability check now probes each candidate and falls through to the first that executes, so a stale `uv`-managed Python no longer fails the whole session.
- Kernel spawn reuses the probed runtime from the availability result instead of re-resolving independently.
- Expanded test coverage for enumeration, fallback, and env isolation between candidates.
2026-05-31 02:00:18 +02:00

240 lines
6.9 KiB
TypeScript

/**
* 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, string | undefined>): 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<string, string | undefined>;
/** 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<string, string | undefined>): Record<string, string | undefined> {
const filtered: Record<string, string | undefined> = {};
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;
}
/**
* Apply a venv-style PATH/VIRTUAL_ENV layout onto a fresh copy of `baseEnv` for
* the interpreter living in `binDir`.
*/
function applyVenvEnv(
baseEnv: Record<string, string | undefined>,
venvPath: string,
binDir: string,
): Record<string, string | undefined> {
const env = { ...baseEnv };
env.VIRTUAL_ENV = venvPath;
const pathKey = resolvePathKey(env);
const currentPath = env[pathKey];
env[pathKey] = currentPath ? `${binDir}${path.delimiter}${currentPath}` : binDir;
return env;
}
function venvBinDir(venvPath: string): string {
return process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin");
}
/**
* Enumerate candidate Python runtimes in priority order: an active/project venv,
* the managed `~/.omp/python-env`, then the system interpreter on PATH. Every
* candidate that physically exists is returned so callers can probe each in turn
* rather than committing to the first — a managed env left behind by a removed
* `uv` install no longer shadows a working system Python.
*/
export function enumeratePythonRuntimes(cwd: string, baseEnv: Record<string, string | undefined>): PythonRuntime[] {
const runtimes: PythonRuntime[] = [];
const seen = new Set<string>();
const push = (runtime: PythonRuntime): void => {
if (seen.has(runtime.pythonPath)) return;
seen.add(runtime.pythonPath);
runtimes.push(runtime);
};
const venvPath = baseEnv.VIRTUAL_ENV ?? resolveVenvPath(cwd);
if (venvPath) {
const binDir = venvBinDir(venvPath);
const pythonCandidate = path.join(binDir, process.platform === "win32" ? "python.exe" : "python");
if (fs.existsSync(pythonCandidate)) {
push({ pythonPath: pythonCandidate, env: applyVenvEnv(baseEnv, venvPath, binDir), venvPath });
}
}
const managed = resolveManagedPythonCandidate();
if (fs.existsSync(managed.pythonPath)) {
const managedBin = path.dirname(managed.pythonPath);
push({
pythonPath: managed.pythonPath,
env: applyVenvEnv(baseEnv, managed.venvPath, managedBin),
venvPath: managed.venvPath,
});
}
const systemPath = $which("python") ?? $which("python3");
if (systemPath) {
push({ pythonPath: systemPath, env: { ...baseEnv } });
}
return runtimes;
}
/**
* Resolve the highest-priority Python runtime. Prefer {@link enumeratePythonRuntimes}
* when you can probe candidates; this returns only the first one and throws when
* no interpreter exists.
*/
export function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>): PythonRuntime {
const [runtime] = enumeratePythonRuntimes(cwd, baseEnv);
if (!runtime) {
throw new Error("Python executable not found on PATH");
}
return runtime;
}