Files
oh-my-pi/packages/coding-agent/src/ipy/runtime.ts
T

224 lines
6.3 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 os from "node:os";
import * as path from "node:path";
import { $env } from "@oh-my-pi/pi-utils";
import { CONFIG_DIR_NAME } from "../config";
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",
]);
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",
"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 path.join(os.homedir(), CONFIG_DIR_NAME, "python-env");
}
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;
const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return undefined;
}
/**
* Resolve the windowless Python executable (pythonw.exe) on Windows.
* Falls back to the regular Python path if pythonw.exe is not available.
*/
function resolveWindowlessPython(pythonPath: string): string {
if (process.platform !== "win32") return pythonPath;
const pythonwPath = pythonPath.replace(/python\.exe$/i, "pythonw.exe");
if (pythonwPath !== pythonPath && fs.existsSync(pythonwPath)) {
return pythonwPath;
}
return pythonPath;
}
/**
* Resolve Python runtime including executable path, environment, and venv detection.
*/
export function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>): 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: resolveWindowlessPython(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: resolveWindowlessPython(managed.pythonPath),
env,
venvPath: managed.venvPath,
};
}
const pythonPath = Bun.which("python") ?? Bun.which("python3");
if (!pythonPath) {
throw new Error("Python executable not found on PATH");
}
return {
pythonPath: resolveWindowlessPython(pythonPath),
env,
};
}