agent-ecosystem/src/main/utils/shellEnv.ts
2026-05-22 00:17:54 +03:00

331 lines
11 KiB
TypeScript

/**
* Interactive shell environment resolver.
*
* Resolves the user's interactive shell environment (PATH, etc.) by spawning
* a login/interactive shell and reading its exported variables. The result is
* cached for the lifetime of the process.
*
* Extracted from TeamProvisioningService for reuse by ScheduledTaskExecutor
* and any other service that needs the user's shell environment.
*/
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { spawn } from 'child_process';
const logger = createLogger('Utils:shellEnv');
const SHELL_ENV_TIMEOUT_MS = 12_000;
const SHELL_ENV_BEST_EFFORT_TIMEOUT_MS = 5_000;
const SHELL_ENV_FAILURE_COOLDOWN_MS = 60_000;
let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null;
let shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null;
let shellEnvFailureCooldownUntil = 0;
let lastShellEnvFailureMessage: string | null = null;
export interface ShellEnvResolveProgress {
phase: string;
message: string;
source?: string;
}
export interface ShellEnvResolveOptions {
onProgress?: (progress: ShellEnvResolveProgress) => void;
/**
* Stable diagnostic label for the caller that initiated the shell probe.
* Keep this to a short feature/service id, not a filesystem path.
*/
source?: string;
}
export interface ShellEnvBestEffortResolveOptions extends ShellEnvResolveOptions {
/**
* Max time to wait on the critical path before returning fallbackEnv.
* By default, the full shell resolve continues in the background and caches
* on success. Set background=false for hot paths that only want cached env
* or an immediate fallback.
*/
timeoutMs?: number;
/**
* Whether a slow shell probe should continue in the background after the
* caller falls back. Disable this for startup/status hot paths where a
* delayed hard timeout would only create log noise and process pressure.
*/
background?: boolean;
/**
* Returned when shell env is not ready quickly enough. This is intentionally
* not cached as a real shell env.
*/
fallbackEnv?: NodeJS.ProcessEnv;
}
function emitProgress(
options: ShellEnvResolveOptions | undefined,
phase: string,
message: string
): void {
const source = normalizeShellEnvSource(options?.source);
options?.onProgress?.(source ? { phase, message, source } : { phase, message });
}
function normalizeShellEnvSource(source: string | undefined): string | null {
const trimmed = source?.trim();
if (!trimmed) {
return null;
}
return trimmed.replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 80);
}
function formatShellEnvSource(options: ShellEnvResolveOptions | undefined): string {
const source = normalizeShellEnvSource(options?.source);
return source ? ` source=${source}` : '';
}
function rememberShellEnvFailure(message: string): void {
lastShellEnvFailureMessage = message;
shellEnvFailureCooldownUntil = Date.now() + SHELL_ENV_FAILURE_COOLDOWN_MS;
}
function clearShellEnvFailure(): void {
lastShellEnvFailureMessage = null;
shellEnvFailureCooldownUntil = 0;
}
function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv {
const parsed: NodeJS.ProcessEnv = {};
const lines = content.split('\0');
for (const line of lines) {
if (!line) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex);
const value = line.slice(separatorIndex + 1);
parsed[key] = value;
}
return parsed;
}
async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.ProcessEnv> {
const envDump = await new Promise<string>((resolve, reject) => {
const child = spawn(shellPath, args, {
env: process.env,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
const chunks: Buffer[] = [];
let settled = false;
let timeoutHandle: NodeJS.Timeout | null = setTimeout(() => {
timeoutHandle = null;
child.kill();
// SIGKILL fallback if SIGTERM is ignored (e.g., shell stuck on .zshrc)
setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
/* already dead */
}
}, 3000);
if (!settled) {
settled = true;
reject(new Error('shell env resolve timeout'));
}
}, SHELL_ENV_TIMEOUT_MS);
child.stdout?.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
child.once('error', (error) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
if (!settled) {
settled = true;
reject(error);
}
});
child.once('close', (code: number | null, signal: NodeJS.Signals | null) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (!settled) {
settled = true;
if (chunks.length === 0 && (code !== 0 || signal)) {
reject(
new Error(
signal
? `shell env command exited with signal ${signal}`
: `shell env command exited with code ${code}`
)
);
return;
}
resolve(Buffer.concat(chunks).toString('utf8'));
}
});
});
return parseNullSeparatedEnv(envDump);
}
/**
* Resolve the user's interactive shell environment.
*
* Tries login shell first (`-lic`), falls back to interactive (`-ic`).
* On Windows returns empty object. Result is cached after first success.
*/
export async function resolveInteractiveShellEnv(
options: ShellEnvResolveOptions = {}
): Promise<NodeJS.ProcessEnv> {
if (cachedInteractiveShellEnv) {
emitProgress(options, 'shell-env-cached', 'Using cached shell environment...');
return cachedInteractiveShellEnv;
}
if (shellEnvResolvePromise) {
emitProgress(options, 'shell-env-waiting', 'Waiting for shell environment...');
return shellEnvResolvePromise;
}
if (process.platform === 'win32') {
emitProgress(options, 'shell-env-skipped', 'Skipping shell environment on Windows...');
cachedInteractiveShellEnv = {};
return cachedInteractiveShellEnv;
}
shellEnvResolvePromise = (async () => {
const shellPath = process.env.SHELL || '/bin/zsh';
try {
emitProgress(options, 'shell-env-login', 'Reading login shell environment...');
const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']);
cachedInteractiveShellEnv = loginEnv;
clearShellEnvFailure();
return loginEnv;
} catch (loginError) {
const loginMessage = loginError instanceof Error ? loginError.message : String(loginError);
try {
emitProgress(options, 'shell-env-interactive', 'Trying interactive shell environment...');
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
cachedInteractiveShellEnv = interactiveEnv;
clearShellEnvFailure();
return interactiveEnv;
} catch (interactiveError) {
const interactiveMessage =
interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
logger.warn(
`Failed to resolve shell env after login and interactive probes${formatShellEnvSource(
options
)}: login=${loginMessage}; interactive=${interactiveMessage}`
);
rememberShellEnvFailure(interactiveMessage);
emitProgress(options, 'shell-env-fallback', 'Using current process environment...');
return {};
}
} finally {
shellEnvResolvePromise = null;
}
})();
return shellEnvResolvePromise;
}
/**
* Resolve shell env without making the caller wait for slow prompt/plugin init.
*
* This is deliberately additive: fallbackEnv is returned only to the current
* caller, never cached. A successful background resolve still populates the
* normal interactive-shell cache used by buildMergedCliPath/buildEnrichedEnv.
*/
export async function resolveInteractiveShellEnvBestEffort(
options: ShellEnvBestEffortResolveOptions = {}
): Promise<NodeJS.ProcessEnv> {
if (cachedInteractiveShellEnv) {
emitProgress(options, 'shell-env-cached', 'Using cached shell environment...');
return cachedInteractiveShellEnv;
}
if (process.platform === 'win32') {
return resolveInteractiveShellEnv(options);
}
const fallbackEnv = options.fallbackEnv ?? {};
const timeoutMs = Math.max(0, options.timeoutMs ?? SHELL_ENV_BEST_EFFORT_TIMEOUT_MS);
const startedAt = Date.now();
if (options.background === false) {
emitProgress(options, 'shell-env-best-effort-fallback', 'Using fallback shell environment...');
return fallbackEnv;
}
if (!shellEnvResolvePromise && startedAt < shellEnvFailureCooldownUntil) {
const retryInMs = Math.max(0, shellEnvFailureCooldownUntil - startedAt);
emitProgress(
options,
'shell-env-failure-cooldown',
lastShellEnvFailureMessage
? `Using fallback shell environment after recent failure: ${lastShellEnvFailureMessage}`
: `Using fallback shell environment for ${retryInMs}ms after recent failure...`
);
return fallbackEnv;
}
const resolvePromise = resolveInteractiveShellEnv(options);
if (timeoutMs === 0) {
emitProgress(options, 'shell-env-best-effort-fallback', 'Using fallback shell environment...');
return fallbackEnv;
}
let timeoutHandle: NodeJS.Timeout | null = null;
const fallbackPromise = new Promise<NodeJS.ProcessEnv>((resolve) => {
timeoutHandle = setTimeout(() => {
timeoutHandle = null;
emitProgress(
options,
'shell-env-best-effort-timeout',
'Shell environment is still resolving; using fallback for now...'
);
resolve(fallbackEnv);
}, timeoutMs);
timeoutHandle.unref?.();
});
try {
const resolvedEnv = await Promise.race([resolvePromise, fallbackPromise]);
if (!cachedInteractiveShellEnv && shellEnvFailureCooldownUntil > startedAt) {
return fallbackEnv;
}
return resolvedEnv;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
/**
* Clear the cached shell environment. Useful for testing.
*/
export function clearShellEnvCache(): void {
cachedInteractiveShellEnv = null;
shellEnvResolvePromise = null;
clearShellEnvFailure();
}
/**
* Return the cached shell environment synchronously, or null if not yet resolved.
*
* Use this when you need the shell env but cannot afford to wait for resolution
* (e.g. synchronous PATH enrichment with async pre-warming at startup).
*/
export function getCachedShellEnv(): NodeJS.ProcessEnv | null {
return cachedInteractiveShellEnv;
}
/**
* HOME from login/interactive shell when resolved, else Electron/Node home.
* Matches TeamProvisioningService so CLI reads the same ~/.claude as the terminal.
*/
export function getShellPreferredHome(): string {
const fromShell = getCachedShellEnv()?.HOME?.trim();
return fromShell || getHomeDir();
}