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 { formatErrorMessage } from "./errors.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" ;
let lastAppliedKeys: string[] = [];
let cachedShellPath: string | null | undefined;
let cachedEtcShells: Set<string> | null | undefined;
let nextExecCacheId = 1 ;
const loginShellEnvProbeCache = new Map<string, Array<[string, string]>>();
const execCacheIds = new WeakMap<object, number>();
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<string> | 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();
return registeredShells?.has(shell) === true ;
}
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<string, string> {
const shellEnv = new Map<string, string>();
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;
}
function resolveExecCacheId(exec: typeof execFileSync | undefined): string {
if (!exec) {
return "default" ;
}
const key = exec as object;
let id = execCacheIds.get(key);
if (!id) {
id = nextExecCacheId;
nextExecCacheId += 1 ;
execCacheIds.set(key, id);
}
return `exec:${id}`;
}
function createLoginShellEnvCacheKey(params: {
shell: string;
timeoutMs: number;
exec?: typeof execFileSync;
execEnv: NodeJS.ProcessEnv;
}): string {
const startupEnvEntries = Object.entries(params.execEnv)
.filter(([key]) => {
if (
key === "HOME" ||
key === "PATH" ||
key === "TERM" ||
key === "LANG" ||
key === "LC_ALL" ||
key === "LC_CTYPE" ||
key === "USER" ||
key === "LOGNAME" ||
key === "TMPDIR"
) {
return true ;
}
return key.startsWith("XDG_" ) || key.startsWith("OPENCLAW_" );
})
.toSorted(([left], [right]) => left.localeCompare(right));
return JSON.stringify([
params.shell,
params.timeoutMs,
resolveExecCacheId(params.exec),
startupEnvEntries,
]);
}
type LoginShellEnvProbeResult =
| { ok: true ; shellEnv: Map<string, string> }
| { ok: false ; error: string };
function probeLoginShellEnv(params: {
env: NodeJS.ProcessEnv;
timeoutMs?: number;
exec?: typeof execFileSync;
}): LoginShellEnvProbeResult {
const exec = params.exec ?? execFileSync;
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
const shell = resolveShell(params.env);
const execEnv = resolveShellExecEnv(params.env);
const cacheKey = createLoginShellEnvCacheKey({
shell,
timeoutMs,
exec: params.exec,
execEnv,
});
const cached = loginShellEnvProbeCache.get(cacheKey);
if (cached) {
return { ok: true , shellEnv: new Map(cached) };
}
try {
const stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs });
const shellEnv = parseShellEnv(stdout);
loginShellEnvProbeCache.set(cacheKey, [...shellEnv.entries()]);
return { ok: true , shellEnv };
} catch (err) {
return { ok: false , error: formatErrorMessage(err) };
}
}
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<typeof console, "warn" >;
timeoutMs?: number;
exec?: typeof execFileSync;
};
function hasExplicitEnvBinding(env: NodeJS.ProcessEnv, key: string): boolean {
return Object.prototype.hasOwnProperty.call(env, key);
}
export function loadShellEnvFallback(opts: ShellEnvFallbackOptions): ShellEnvFallbackResult {
const logger = opts.logger ?? console;
if (!opts.enabled) {
lastAppliedKeys = [];
return { ok: true , applied: [], skippedReason: "disabled" };
}
const hasAnyKey = opts.expectedKeys.some((key) => hasExplicitEnvBinding(opts.env, key));
if (hasAnyKey) {
lastAppliedKeys = [];
return { ok: true , applied: [], skippedReason: "already-has-keys" };
}
const probe = probeLoginShellEnv({
env: opts.env,
timeoutMs: opts.timeoutMs,
exec: opts.exec,
});
if (!probe.ok) {
logger.warn(`[openclaw] shell env fallback failed: ${probe.error}`);
lastAppliedKeys = [];
return { ok: false , error: probe.error, applied: [] };
}
const applied: string[] = [];
for (const key of opts.expectedKeys) {
if (hasExplicitEnvBinding(opts.env, key)) {
continue ;
}
const value = probe.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 probe = probeLoginShellEnv({
env: opts.env,
timeoutMs: opts.timeoutMs,
exec: opts.exec,
});
if (!probe.ok) {
cachedShellPath = null ;
return cachedShellPath;
}
const shellPath = probe.shellEnv.get("PATH" )?.trim();
cachedShellPath = shellPath && shellPath.length > 0 ? shellPath : null ;
return cachedShellPath;
}
export function resetShellPathCacheForTests(): void {
cachedShellPath = undefined;
cachedEtcShells = undefined;
loginShellEnvProbeCache.clear();
nextExecCacheId = 1 ;
}
export function getShellEnvAppliedKeys(): string[] {
return [...lastAppliedKeys];
}
Messung V0.5 in Prozent C=94 H=99 G=96
¤ Dauer der Verarbeitung: 0.10 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland