/** * Shell-env resolver — AI-OS / router-arc P0-C (RECON §2). * * Problem: when the Tauri sidecar is launched from a macOS/Linux GUI (Finder, * Dock, .app bundle) it inherits a *bare* PATH — the login shell's profile * (`~/.zprofile`, `~/.bash_profile`, nvm/homebrew/asdf shims, …) is never * sourced. Tool detection (`which claude`) and the external-tool runner then * fail to find CLIs the user installed via those shims. * * Fix: resolve the *login-interactive* shell environment once per process by * spawning `${SHELL} -l -i -c 'env -0'`, parse the null-separated dump, and * expose the resolved PATH to callers. Windows is unaffected (PATH comes from * the registry / system env, inherited correctly) so we short-circuit to * `process.env` there. * * Contract: * - Never throws — every failure path resolves to the `process` fallback. * - Never logs secrets (no logging of env values at all). * - Process-lifetime cache + single-flight: concurrent callers share one * spawn; a successful login-shell result is cached forever. * - On failure a 60s cooldown gates re-spawn attempts. */ import { spawn } from 'node:child_process'; export interface ShellEnvResult { env: Record; source: 'login-shell' | 'process'; } export interface ResolveShellEnvOptions { /** Login-shell spawn timeout before SIGTERM (default 12_000ms). */ timeoutMs?: number; /** Test seam: platform override (defaults to process.platform). */ platform?: NodeJS.Platform; /** Test seam: spawn override (defaults to child_process.spawn). */ spawnFn?: typeof spawn; } const DEFAULT_TIMEOUT_MS = 12_000; const SIGKILL_GRACE_MS = 2_000; const FAILURE_COOLDOWN_MS = 60_000; // ── Process-lifetime module state ─────────────────────────────────── let cached: ShellEnvResult | null = null; let inflight: Promise | null = null; let cooldownUntil = 0; function currentPlatform(opts?: ResolveShellEnvOptions): NodeJS.Platform { return opts?.platform ?? process.platform; } function filterStringEnv(src: NodeJS.ProcessEnv): Record { const out: Record = {}; for (const [key, value] of Object.entries(src)) { if (typeof value === 'string') out[key] = value; } return out; } function processFallback(): ShellEnvResult { return { env: filterStringEnv(process.env), source: 'process' }; } function parseEnv0(payload: string): Record { const out: Record = {}; for (const entry of payload.split('\0')) { if (!entry) continue; const eq = entry.indexOf('='); if (eq <= 0) continue; out[entry.slice(0, eq)] = entry.slice(eq + 1); } return out; } /** * Spawn the shell once with the given args, collect stdout, and parse an * `env -0` dump. Resolves `null` on any non-clean outcome (spawn error, * non-zero exit, timeout, empty/garbled output) so the caller can fall back. */ function spawnShellEnv( args: string[], timeoutMs: number, spawnFn: typeof spawn, ): Promise | null> { return new Promise((resolve) => { const shell = process.env.SHELL || '/bin/sh'; let child: ReturnType; try { child = spawnFn(shell, args, { stdio: ['ignore', 'pipe', 'ignore'] }); } catch { resolve(null); return; } let out = ''; let settled = false; const settle = (value: Record | null) => { if (settled) return; settled = true; clearTimeout(sigtermTimer); clearTimeout(sigkillTimer); resolve(value); }; const sigtermTimer = setTimeout(() => { try { child.kill('SIGTERM'); } catch { /* already gone */ } }, timeoutMs); const sigkillTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already gone */ } settle(null); }, timeoutMs + SIGKILL_GRACE_MS); child.stdout?.on('data', (chunk: Buffer | string) => { out += chunk.toString(); }); child.once('error', () => settle(null)); child.once('exit', (code) => { settle(code === 0 && out.includes('=') ? parseEnv0(out) : null); }); }); } async function doResolve(opts: ResolveShellEnvOptions): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const spawnFn = opts.spawnFn ?? spawn; try { // Prefer login + interactive (sources both profile and rc files). const interactive = await spawnShellEnv(['-l', '-i', '-c', 'env -0'], timeoutMs, spawnFn); if (interactive && interactive.PATH) { return { env: interactive, source: 'login-shell' }; } // Fall back to login-only (some shells reject `-i` without a tty). const loginOnly = await spawnShellEnv(['-l', '-c', 'env -0'], timeoutMs, spawnFn); if (loginOnly && loginOnly.PATH) { return { env: loginOnly, source: 'login-shell' }; } } catch { // fall through to process fallback } return processFallback(); } /** * Resolve the login-shell environment (async). Cached + single-flight. Always * resolves — never rejects. Windows short-circuits to the process env. */ export async function resolveShellEnv( opts: ResolveShellEnvOptions = {}, ): Promise { if (cached) return cached; if (currentPlatform(opts) === 'win32') { cached = processFallback(); return cached; } if (inflight) return inflight; if (Date.now() < cooldownUntil) return processFallback(); inflight = doResolve(opts) .then((result) => { if (result.source === 'login-shell') { cached = result; } else { cooldownUntil = Date.now() + FAILURE_COOLDOWN_MS; } return result; }) .catch(() => { // doResolve never throws, but guard the contract regardless. cooldownUntil = Date.now() + FAILURE_COOLDOWN_MS; return processFallback(); }) .finally(() => { inflight = null; }); return inflight; } /** * Synchronous best-effort accessor for hot paths (tool detection, runner env). * Returns the cached login-shell result if available, otherwise the process env * immediately while kicking off a background resolve for the next caller. */ export function resolveShellEnvBestEffort(): ShellEnvResult { if (cached) return cached; if (currentPlatform() === 'win32') { cached = processFallback(); return cached; } if (!inflight && Date.now() >= cooldownUntil) { void resolveShellEnv(); } return processFallback(); } /** * The resolved login-shell PATH, or null when unavailable (Windows, not yet * resolved, or resolution failed). Callers merge this into their lookup/runner * env; null means "keep existing behavior". */ export function resolvedShellPath(): string | null { const result = resolveShellEnvBestEffort(); return result.source === 'login-shell' ? (result.env.PATH ?? null) : null; } /** * Union two POSIX PATH strings, shell PATH entries first, de-duplicated, so the * login-shell shims win without dropping anything already present in `base`. */ export function mergePathValue(shellPath: string, basePath: string | undefined): string { const seen = new Set(); const parts: string[] = []; for (const segment of `${shellPath}:${basePath ?? ''}`.split(':')) { if (segment && !seen.has(segment)) { seen.add(segment); parts.push(segment); } } return parts.join(':'); } /** Test-only: reset process-lifetime cache/cooldown/single-flight state. */ export function __resetShellEnvStateForTests(): void { cached = null; inflight = null; cooldownUntil = 0; }