Merge PR #4455: feat(coding-agent): auto-load direnv environment into the bash session (@mattwilkinsonn)

# Conflicts:
#	packages/coding-agent/src/exec/bash-executor.ts
#	packages/coding-agent/test/bash-executor.test.ts
This commit is contained in:
can1357
2026-07-23 17:38:28 +02:00
7 changed files with 678 additions and 13 deletions
@@ -10,6 +10,7 @@ import { Settings, type ShellMinimizerSettings } from "../config/settings";
import { OutputSink } from "../session/streaming-output";
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
import { loadDirenvEnv } from "./direnv";
import { buildNonInteractiveEnv } from "./non-interactive-env";
export interface BashExecutorOptions {
@@ -56,6 +57,78 @@ export interface BashResult {
workingDir?: string;
}
/** POSIX-safe variable name — gates which direnv unsets we inject into the
* command line, so a hostile `.envrc` can't smuggle shell syntax through
* `unset`. `.envrc` never produces non-identifier names in practice. */
const SAFE_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
export interface DirenvPreflightOptions {
/** Caller-supplied env overlay; these values win over direnv-provided ones. */
callerEnv?: Record<string, string>;
signal?: AbortSignal;
/** Full direnv-load budget (`bash.direnvLoadTimeoutMs`). A positive
* `callerTimeoutMs` clamps the effective load below this; `0`/undefined
* leaves the full budget. */
timeoutMs?: number;
/** The caller's command deadline (ms). A positive value clamps the direnv
* load so a cold `.envrc` can't outlast a short-timeout command; `0` or
* undefined means "no caller clamp" — the load keeps its full `timeoutMs`
* budget (a disabled command deadline is NOT a 0 ms load). Centralizing the
* clamp here keeps every backend (executeBash, ACP terminal, PTY) on one
* contract instead of each re-deriving it. */
callerTimeoutMs?: number;
/** `bash.direnv` setting — `"off"` skips the load entirely. */
direnvSetting: "auto" | "off";
/** Shell wrapper prefix (profiler/strace) to place *after* the unset prefix,
* matching `executeBash`'s ordering. Backends that apply their own shell
* wrapping (ACP `wrapShellLineForClientTerminal`) omit this. */
commandPrefix?: string | undefined;
}
/**
* Load the repo's direnv/devenv env and fold it into a `(command, env)` pair so
* every bash backend (one-shot `executeBash`, ACP client terminal, PTY) exposes
* the same devenv tools. Encapsulates: load the diff, merge `set` under the
* caller's overlay (caller wins), and prepend a regex-gated `unset -v` for
* variables the `.envrc` removes (skipping any the caller re-supplied).
*
* Returns the possibly-prefixed command plus the merged env, or the inputs
* unchanged (`env` = `callerEnv`) when direnv is off, absent, or has no `.envrc`.
* Pure transform: does NOT layer non-interactive env defaults — that stays the
* caller's job (so interactive PTY/ACP paths keep their own env shape).
*/
export async function applyDirenvPreflight(
command: string,
cwd: string,
opts: DirenvPreflightOptions,
): Promise<{ command: string; env: Record<string, string> | undefined }> {
const withPrefix = (line: string): string => (opts.commandPrefix ? `${opts.commandPrefix} ${line}` : line);
// A positive caller deadline clamps the direnv load below its full budget so
// a cold `.envrc` can't outlast a short-timeout command; `0`/undefined means
// "no caller clamp" (a disabled command deadline is not a 0 ms load). Every
// backend routes through here, so the clamp lives in one place.
const loadTimeoutMs =
opts.callerTimeoutMs !== undefined && opts.callerTimeoutMs > 0
? Math.min(opts.timeoutMs ?? opts.callerTimeoutMs, opts.callerTimeoutMs)
: opts.timeoutMs;
const direnvDiff =
opts.direnvSetting === "off" ? null : await loadDirenvEnv(cwd, { timeoutMs: loadTimeoutMs, signal: opts.signal });
if (!direnvDiff) {
return { command: withPrefix(command), env: opts.callerEnv };
}
// The caller's explicit env still wins over direnv-provided values.
const mergedEnv = { ...direnvDiff.set, ...opts.callerEnv };
// direnv can also *remove* inherited variables (a `.envrc` doing
// `unset AWS_PROFILE`). An env overlay can only add/override, so prepend a
// real `unset` for those — unless the caller re-supplied the same var
// explicitly, in which case the caller wins.
const direnvUnsets = direnvDiff.unset.filter(
name => !(opts.callerEnv && name in opts.callerEnv) && SAFE_ENV_NAME.test(name),
);
const unsetPrefix = direnvUnsets.length > 0 ? `unset -v ${direnvUnsets.join(" ")}; ` : "";
return { command: `${unsetPrefix}${withPrefix(command)}`, env: mergedEnv };
}
const shellSessions = new Map<string, Shell>();
const brokenShellSessions = new Set<string>();
const shellSessionQuarantines = new Map<string, Promise<unknown>>();
@@ -279,15 +352,25 @@ export async function executeBash(command: string, options?: BashExecutorOptions
const minimizer = buildMinimizerOptions(settings.getGroup("shellMinimizer"));
const commandCwd = resolveShellCwd(options?.cwd);
const commandEnv = buildNonInteractiveEnv(options?.env);
// Apply command prefix if configured
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
// Fold the repo's direnv/devenv env into the command + env so devenv tools
// land on PATH; the caller's explicit `env` still wins. Thread the caller's
// signal + timeout so an aborted / short-timeout call can't hang on a cold
// `.envrc` load before the abort listener is installed. The helper applies
// the configured shell `prefix` after any `unset -v` it prepends.
const preflight = await applyDirenvPreflight(command, commandCwd ?? process.cwd(), {
callerEnv: options?.env,
signal: options?.signal,
timeoutMs: settings.get("bash.direnvLoadTimeoutMs"),
callerTimeoutMs: options?.timeout,
direnvSetting: settings.get("bash.direnv"),
commandPrefix: prefix,
});
const commandEnv = buildNonInteractiveEnv(preflight.env);
const runCdInPersistentShell = options?.useUserShell === true && !prefix && isPersistentShellCdCommand(command);
const finalCommand =
options?.useUserShell === true && !bashShell && !runCdInPersistentShell
? buildUserShellCommand(shell, args, prefixedCommand)
: prefixedCommand;
? buildUserShellCommand(shell, args, preflight.command)
: preflight.command;
// Create output sink for truncation and artifact handling
const sink = new OutputSink({