import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isTruthyEnvValue } from "./env.js"; import { sanitizeHostExecEnv } from "./host-env-security.js"; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024; const DEFAULT_SHELL = "/bin/sh"; const TRUSTED_SHELL_PREFIXES = [ "/bin/", "/usr/bin/", "/usr/local/bin/", "/opt/homebrew/bin/", "/run/current-system/sw/bin/", ]; let lastAppliedKeys: string[] = []; let cachedShellPath: string | null | undefined; let cachedEtcShells: Set | null | undefined; function resolveShellExecEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const execEnv = sanitizeHostExecEnv({ baseEnv: env }); // Startup-file resolution must stay pinned to the real user home. const home = os.homedir().trim(); if (home) { execEnv.HOME = home; } else { delete execEnv.HOME; } // Avoid zsh startup-file redirection via env poisoning. delete execEnv.ZDOTDIR; return execEnv; } function resolveTimeoutMs(timeoutMs: number | undefined): number { if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) { return DEFAULT_TIMEOUT_MS; } return Math.max(0, timeoutMs); } function readEtcShells(): Set | null { if (cachedEtcShells !== undefined) { return cachedEtcShells; } try { const raw = fs.readFileSync("/etc/shells", "utf8"); const entries = raw .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("#") && path.isAbsolute(line)); cachedEtcShells = new Set(entries); } catch { cachedEtcShells = null; } return cachedEtcShells; } function isTrustedShellPath(shell: string): boolean { if (!path.isAbsolute(shell)) { return false; } const normalized = path.normalize(shell); if (normalized !== shell) { return false; } // Primary trust anchor: shell registered in /etc/shells. const registeredShells = readEtcShells(); if (registeredShells?.has(shell)) { return true; } // Fallback for environments where /etc/shells is incomplete/unavailable. if (!TRUSTED_SHELL_PREFIXES.some((prefix) => shell.startsWith(prefix))) { return false; } try { fs.accessSync(shell, fs.constants.X_OK); return true; } catch { return false; } } function resolveShell(env: NodeJS.ProcessEnv): string { const shell = env.SHELL?.trim(); if (shell && isTrustedShellPath(shell)) { return shell; } return DEFAULT_SHELL; } function execLoginShellEnvZero(params: { shell: string; env: NodeJS.ProcessEnv; exec: typeof execFileSync; timeoutMs: number; }): Buffer { return params.exec(params.shell, ["-l", "-c", "env -0"], { encoding: "buffer", timeout: params.timeoutMs, maxBuffer: DEFAULT_MAX_BUFFER_BYTES, env: params.env, stdio: ["ignore", "pipe", "pipe"], }); } function parseShellEnv(stdout: Buffer): Map { const shellEnv = new Map(); const parts = stdout.toString("utf8").split("\0"); for (const part of parts) { if (!part) { continue; } const eq = part.indexOf("="); if (eq <= 0) { continue; } const key = part.slice(0, eq); const value = part.slice(eq + 1); if (!key) { continue; } shellEnv.set(key, value); } return shellEnv; } export type ShellEnvFallbackResult = | { ok: true; applied: string[]; skippedReason?: never } | { ok: true; applied: []; skippedReason: "already-has-keys" | "disabled" } | { ok: false; error: string; applied: [] }; export type ShellEnvFallbackOptions = { enabled: boolean; env: NodeJS.ProcessEnv; expectedKeys: string[]; logger?: Pick; timeoutMs?: number; exec?: typeof execFileSync; }; export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFallbackResult { const logger = opts.logger ?? console; const exec = opts.exec ?? execFileSync; if (!opts.enabled) { lastAppliedKeys = []; return { ok: true, applied: [], skippedReason: "disabled" }; } const hasAnyKey = opts.expectedKeys.some((key) => Boolean(opts.env[key]?.trim())); if (hasAnyKey) { lastAppliedKeys = []; return { ok: true, applied: [], skippedReason: "already-has-keys" }; } const timeoutMs = resolveTimeoutMs(opts.timeoutMs); const shell = resolveShell(opts.env); const execEnv = resolveShellExecEnv(opts.env); let stdout: Buffer; try { stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger.warn(`[openclaw] shell env fallback failed: ${msg}`); lastAppliedKeys = []; return { ok: false, error: msg, applied: [] }; } const shellEnv = parseShellEnv(stdout); const applied: string[] = []; for (const key of opts.expectedKeys) { if (opts.env[key]?.trim()) { continue; } const value = shellEnv.get(key); if (!value?.trim()) { continue; } opts.env[key] = value; applied.push(key); } lastAppliedKeys = applied; return { ok: true, applied }; } export function shouldEnableShellEnvFallback(env: NodeJS.ProcessEnv): boolean { return isTruthyEnvValue(env.OPENCLAW_LOAD_SHELL_ENV); } export function shouldDeferShellEnvFallback(env: NodeJS.ProcessEnv): boolean { return isTruthyEnvValue(env.OPENCLAW_DEFER_SHELL_ENV_FALLBACK); } export function resolveShellEnvFallbackTimeoutMs(env: NodeJS.ProcessEnv): number { const raw = env.OPENCLAW_SHELL_ENV_TIMEOUT_MS?.trim(); if (!raw) { return DEFAULT_TIMEOUT_MS; } const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed)) { return DEFAULT_TIMEOUT_MS; } return Math.max(0, parsed); } export function getShellPathFromLoginShell(opts: { env: NodeJS.ProcessEnv; timeoutMs?: number; exec?: typeof execFileSync; platform?: NodeJS.Platform; }): string | null { if (cachedShellPath !== undefined) { return cachedShellPath; } const platform = opts.platform ?? process.platform; if (platform === "win32") { cachedShellPath = null; return cachedShellPath; } const exec = opts.exec ?? execFileSync; const timeoutMs = resolveTimeoutMs(opts.timeoutMs); const shell = resolveShell(opts.env); const execEnv = resolveShellExecEnv(opts.env); let stdout: Buffer; try { stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); } catch { cachedShellPath = null; return cachedShellPath; } const shellEnv = parseShellEnv(stdout); const shellPath = shellEnv.get("PATH")?.trim(); cachedShellPath = shellPath && shellPath.length > 0 ? shellPath : null; return cachedShellPath; } export function resetShellPathCacheForTests(): void { cachedShellPath = undefined; cachedEtcShells = undefined; } export function getShellEnvAppliedKeys(): string[] { return [...lastAppliedKeys]; }