fix(direnv): drain stderr, honor cancellation, and apply unsets in bash direnv preflight

Drain stdout and stderr concurrently so a cold .envrc/devenv load can't fill the stderr pipe and block until the timeout cap. Thread the caller's abort signal and per-call timeout into the direnv preflight (AbortSignal.any + min timeout) so an aborted or short-timeout bash call returns promptly. Return the full direnv export diff and honor variable *removals*: the per-command env overlay can only add/override, so prepend a shell-level 'unset -v' for direnv's unset list, gated by a POSIX-identifier regex and skipped when the caller re-supplied the var. Tests: skip the real-direnv cases when direnv is absent, and isolate HOME/XDG so 'direnv allow' never writes into the developer's global store; add unset coverage.
This commit is contained in:
Matt Wilkinson
2026-07-08 22:37:18 -04:00
parent 47493b0742
commit 5ea0cc8419
3 changed files with 131 additions and 25 deletions
@@ -55,6 +55,11 @@ 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_]*$/;
const shellSessions = new Map<string, Shell>();
const brokenShellSessions = new Set<string>();
const shellSessionQuarantines = new Map<string, Promise<unknown>>();
@@ -218,16 +223,30 @@ export async function executeBash(command: string, options?: BashExecutorOptions
const commandCwd = resolveShellCwd(options?.cwd);
// Load the repo's direnv/devenv env (cached per .envrc) so devenv tools land
// on PATH; the caller's explicit `env` still wins over direnv-provided values.
const direnvEnv =
// 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.
const direnvDiff =
settings.get("bash.direnv") === "off"
? null
: await loadDirenvEnv(commandCwd ?? process.cwd(), {
timeoutMs: settings.get("bash.direnvLoadTimeoutMs"),
timeoutMs: Math.min(
settings.get("bash.direnvLoadTimeoutMs"),
options?.timeout ?? Number.POSITIVE_INFINITY,
),
signal: options?.signal,
});
const commandEnv = buildNonInteractiveEnv(direnvEnv ? { ...direnvEnv, ...options?.env } : options?.env);
const commandEnv = buildNonInteractiveEnv(direnvDiff ? { ...direnvDiff.set, ...options?.env } : options?.env);
// direnv can also *remove* inherited variables (a `.envrc` doing
// `unset AWS_PROFILE`). The per-command 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
? direnvDiff.unset.filter(name => !(options?.env && name in options.env) && SAFE_ENV_NAME.test(name))
: [];
const unsetPrefix = direnvUnsets.length > 0 ? `unset -v ${direnvUnsets.join(" ")}; ` : "";
// Apply command prefix if configured
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
const prefixedCommand = `${unsetPrefix}${prefix ? `${prefix} ${command}` : command}`;
const finalCommand =
options?.useUserShell === true && !bashShell
? buildUserShellCommand(shell, args, prefixedCommand)
+25 -12
View File
@@ -72,27 +72,40 @@ async function runDirenv(
cwd: string,
timeoutMs: number,
env: Record<string, string>,
signal?: AbortSignal,
): Promise<{ exitCode: number; stdout: string }> {
// Bail on the caller's cancellation as well as the per-invocation cap so a
// cold `.envrc` load can't outlive an aborted / short-timeout bash call.
const abortSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
: AbortSignal.timeout(timeoutMs);
const proc = Bun.spawn([bin, ...args], {
cwd,
env,
stdout: "pipe",
stderr: "pipe",
signal: AbortSignal.timeout(timeoutMs),
signal: abortSignal,
});
const stdout = await new Response(proc.stdout as ReadableStream<Uint8Array>).text();
// Drain stdout AND stderr concurrently: a cold `use devenv`/Nix load emits
// enough diagnostics to fill the stderr pipe and block the child forever if
// only stdout is read (it would then wait out `timeoutMs`).
const [stdout] = await Promise.all([
new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
]);
const exitCode = await proc.exited;
return { exitCode, stdout };
}
/** Cache the parsed env per resolved `.envrc` + content hash, so the (possibly
* slow) first export is paid once and a changed `.envrc` re-loads. */
const exportCache = new Map<string, Record<string, string>>();
const exportCache = new Map<string, DirenvExportDiff>();
/**
* Resolve the nearest `.envrc` from `cwd`, auto-allow it, and return its
* `direnv export` environment (set values only). Returns `null` when there is
* no `.envrc`, `direnv` is not installed, or the export fails/times out.
* `direnv export` diff (variables to set, and variables direnv removes).
* Returns `null` when there is no `.envrc`, `direnv` is not installed, or the
* export fails/times out.
*
* Auto-allow is deliberate: OMP already runs the repository's own code, so its
* `.envrc` is trusted under the same model rather than forcing a manual
@@ -100,8 +113,8 @@ const exportCache = new Map<string, Record<string, string>>();
*/
export async function loadDirenvEnv(
cwd: string,
opts?: { timeoutMs?: number },
): Promise<Record<string, string> | null> {
opts?: { timeoutMs?: number; signal?: AbortSignal },
): Promise<DirenvExportDiff | null> {
const envrcPath = await findEnvrc(cwd);
if (!envrcPath) return null;
const bin = direnvBinary();
@@ -121,15 +134,15 @@ export async function loadDirenvEnv(
const timeoutMs = opts?.timeoutMs ?? DEFAULT_DIRENV_TIMEOUT_MS;
const env = cleanSpawnEnv();
try {
await runDirenv(bin, ["allow"], dir, timeoutMs, env);
const { exitCode, stdout } = await runDirenv(bin, ["export", "json"], dir, timeoutMs, env);
await runDirenv(bin, ["allow"], dir, timeoutMs, env, opts?.signal);
const { exitCode, stdout } = await runDirenv(bin, ["export", "json"], dir, timeoutMs, env, opts?.signal);
if (exitCode !== 0) {
logger.warn("direnv export failed", { dir, exitCode });
return null;
}
const { set } = parseDirenvExport(stdout);
exportCache.set(cacheKey, set);
return set;
const diff = parseDirenvExport(stdout);
exportCache.set(cacheKey, diff);
return diff;
} catch (err) {
logger.warn("direnv load failed", { dir, error: err instanceof Error ? err.message : String(err) });
return null;
+83 -9
View File
@@ -1,9 +1,13 @@
import { afterEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
import { findEnvrc, loadDirenvEnv, parseDirenvExport } from "@oh-my-pi/pi-coding-agent/exec/direnv";
import { TempDir } from "@oh-my-pi/pi-utils";
import { $which, TempDir } from "@oh-my-pi/pi-utils";
/** Real-direnv cases need the binary on PATH; skip cleanly when it's absent so
* the graceful-degradation code path (returns `null`) isn't asserted against. */
const hasDirenv = $which("direnv") !== null;
const tmpDirs: TempDir[] = [];
function tmp(): string {
@@ -58,16 +62,50 @@ describe("parseDirenvExport", () => {
});
});
describe("loadDirenvEnv (real direnv, auto-allow)", () => {
describe.skipIf(!hasDirenv)("loadDirenvEnv (real direnv, auto-allow)", () => {
// `direnv allow` writes a trust entry into its data dir. Redirect HOME + the
// XDG dirs to a throwaway tmp so real-direnv cases never leak allow state
// into the dev/CI user's global direnv store.
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
const home = tmp();
for (const key of ["HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"]) {
savedEnv[key] = Bun.env[key];
Bun.env[key] = path.join(home, key.toLowerCase());
}
});
afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete Bun.env[key];
else Bun.env[key] = value;
}
});
it("auto-allows an untrusted .envrc and returns its exported vars + PATH additions", async () => {
const root = tmp();
await fs.mkdir(path.join(root, "bin"), { recursive: true });
await Bun.write(path.join(root, ".envrc"), "export DIRENV_FEATURE_TEST=loaded\nPATH_add bin\n");
const env = await loadDirenvEnv(root);
const diff = await loadDirenvEnv(root);
expect(env?.DIRENV_FEATURE_TEST).toBe("loaded");
expect(env?.PATH).toContain(path.join(root, "bin"));
expect(diff?.set.DIRENV_FEATURE_TEST).toBe("loaded");
expect(diff?.set.PATH).toContain(path.join(root, "bin"));
});
it("reports variables a .envrc unsets", async () => {
const root = tmp();
await Bun.write(path.join(root, ".envrc"), "unset PI_DIRENV_UNSET_TEST\n");
// direnv emits a JSON null for a var only when it was present in the
// spawn env and the `.envrc` removes it, so seed it in the parent env.
// (Avoid a `DIRENV_`-prefixed name — the loader strips those before spawn.)
Bun.env.PI_DIRENV_UNSET_TEST = "present";
try {
const diff = await loadDirenvEnv(root);
expect(diff?.set.PI_DIRENV_UNSET_TEST).toBeUndefined();
expect(diff?.unset).toContain("PI_DIRENV_UNSET_TEST");
} finally {
delete Bun.env.PI_DIRENV_UNSET_TEST;
}
});
it("returns null when there is no .envrc to load", async () => {
@@ -77,14 +115,29 @@ describe("loadDirenvEnv (real direnv, auto-allow)", () => {
it("re-loads when the .envrc content changes (cache keyed by content)", async () => {
const root = tmp();
await Bun.write(path.join(root, ".envrc"), "export DIRENV_CACHE_TEST=one\n");
expect((await loadDirenvEnv(root))?.DIRENV_CACHE_TEST).toBe("one");
expect((await loadDirenvEnv(root))?.set.DIRENV_CACHE_TEST).toBe("one");
await Bun.write(path.join(root, ".envrc"), "export DIRENV_CACHE_TEST=two\n");
expect((await loadDirenvEnv(root))?.DIRENV_CACHE_TEST).toBe("two");
expect((await loadDirenvEnv(root))?.set.DIRENV_CACHE_TEST).toBe("two");
});
});
describe("bash executor direnv wiring (end-to-end)", () => {
describe.skipIf(!hasDirenv)("bash executor direnv wiring (end-to-end)", () => {
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
const home = tmp();
for (const key of ["HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"]) {
savedEnv[key] = Bun.env[key];
Bun.env[key] = path.join(home, key.toLowerCase());
}
});
afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete Bun.env[key];
else Bun.env[key] = value;
}
});
it("exposes direnv-loaded vars to the command while per-call env still wins", async () => {
const root = tmp();
await Bun.write(path.join(root, ".envrc"), "export DIRENV_WIRE_TEST=fromdirenv\nexport OVERRIDE_ME=fromdirenv\n");
@@ -96,4 +149,25 @@ describe("bash executor direnv wiring (end-to-end)", () => {
expect(result.output).toContain("fromdirenv|fromcaller");
});
it("removes variables the .envrc unsets from the command environment", async () => {
const root = tmp();
await Bun.write(path.join(root, ".envrc"), "unset PI_DIRENV_UNSET_E2E\n");
// Inherited from the process env (as an OMP-provided var would be); the
// caller does NOT re-supply it, so direnv's unset must strip it. `printenv`
// exits non-zero and prints nothing when the name is genuinely absent. A
// unique sessionKey forces a fresh shell that captures the var we just set.
// (Avoid a `DIRENV_`-prefixed name — the loader strips those before spawn.)
Bun.env.PI_DIRENV_UNSET_E2E = "leaked";
try {
const result = await executeBash('printenv PI_DIRENV_UNSET_E2E; printf "rc=%s" "$?"', {
cwd: root,
sessionKey: `direnv-unset-${Date.now()}`,
});
expect(result.output).toContain("rc=1");
expect(result.output).not.toContain("leaked");
} finally {
delete Bun.env.PI_DIRENV_UNSET_E2E;
}
});
});