feat(coding-agent): auto-load direnv environment into the bash session

The bash tool's persistent shell didn't carry a repo's direnv/devenv
environment, so devenv-provided tools (moon, project-pinned biome/bun,
toolchains) were off PATH and .envrc-set vars (e.g. GIT_DIR for a jj
secondary workspace) were missing. Resolve the nearest .envrc from the
run cwd, load its env via direnv export json, and merge it under the
caller's per-call env. Gated by bash.direnv (default auto, auto-allows).
This commit is contained in:
Matt Wilkinson
2026-06-30 17:05:14 -04:00
parent f25ab54c59
commit 47493b0742
4 changed files with 269 additions and 1 deletions
@@ -3181,6 +3181,29 @@ export const SETTINGS_SCHEMA = {
},
"bashInterceptor.patterns": { type: "array", default: DEFAULT_BASH_INTERCEPTOR_RULES },
"bash.direnv": {
type: "enum",
values: ["auto", "off"] as const,
default: "auto",
ui: {
tab: "shell",
group: "Bash",
label: "direnv Auto-Load",
description:
"Auto-load (and auto-allow) a repo's direnv/devenv `.envrc` into the bash session so devenv tools and env vars are present without manual `direnv exec`",
},
},
"bash.direnvLoadTimeoutMs": {
type: "number",
default: 30_000,
ui: {
tab: "shell",
group: "Bash",
label: "direnv Load Timeout (ms)",
description:
"Max wait for the first `direnv export` (a cold devenv shell can be slow); on timeout the session runs without the direnv env",
},
},
// Shell output minimizer
"shellMinimizer.enabled": {
type: "boolean",
@@ -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 {
@@ -215,7 +216,15 @@ export async function executeBash(command: string, options?: BashExecutorOptions
const minimizer = buildMinimizerOptions(settings.getGroup("shellMinimizer"));
const commandCwd = resolveShellCwd(options?.cwd);
const commandEnv = buildNonInteractiveEnv(options?.env);
// 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 =
settings.get("bash.direnv") === "off"
? null
: await loadDirenvEnv(commandCwd ?? process.cwd(), {
timeoutMs: settings.get("bash.direnvLoadTimeoutMs"),
});
const commandEnv = buildNonInteractiveEnv(direnvEnv ? { ...direnvEnv, ...options?.env } : options?.env);
// Apply command prefix if configured
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
+137
View File
@@ -0,0 +1,137 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { $which, logger } from "@oh-my-pi/pi-utils";
/** Default cap on a single `direnv` invocation. The first export for a devenv
* `.envrc` can build a shell; callers may raise this via `bash.direnvLoadTimeoutMs`. */
export const DEFAULT_DIRENV_TIMEOUT_MS = 30_000;
/** Walk up from `startDir` to the nearest directory containing an `.envrc`. */
export async function findEnvrc(startDir: string): Promise<string | null> {
let dir = path.resolve(startDir);
for (;;) {
const candidate = path.join(dir, ".envrc");
try {
if ((await fs.stat(candidate)).isFile()) return candidate;
} catch {
// no .envrc here — keep walking up
}
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
export interface DirenvExportDiff {
/** Variables direnv sets to a concrete value. */
set: Record<string, string>;
/** Variables direnv removes (JSON `null`). */
unset: string[];
}
/** Parse `direnv export json` output (`{VAR: value|null}`) into set/unset halves. */
export function parseDirenvExport(jsonText: string): DirenvExportDiff {
const trimmed = jsonText.trim();
if (trimmed.length === 0) return { set: {}, unset: [] };
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return { set: {}, unset: [] };
}
const set: Record<string, string> = {};
const unset: string[] = [];
if (parsed && typeof parsed === "object") {
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (value === null) unset.push(key);
else if (typeof value === "string") set[key] = value;
}
}
return { set, unset };
}
let direnvLookup: { bin: string | null } | undefined;
function direnvBinary(): string | null {
if (!direnvLookup) direnvLookup = { bin: $which("direnv") };
return direnvLookup.bin;
}
/** direnv computes its diff relative to the spawning env; strip any inherited
* direnv state so it loads the target `.envrc` from a clean baseline. */
function cleanSpawnEnv(): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(Bun.env)) {
if (value !== undefined && !key.startsWith("DIRENV_")) out[key] = value;
}
return out;
}
async function runDirenv(
bin: string,
args: string[],
cwd: string,
timeoutMs: number,
env: Record<string, string>,
): Promise<{ exitCode: number; stdout: string }> {
const proc = Bun.spawn([bin, ...args], {
cwd,
env,
stdout: "pipe",
stderr: "pipe",
signal: AbortSignal.timeout(timeoutMs),
});
const stdout = await new Response(proc.stdout 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>>();
/**
* 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.
*
* 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
* `direnv allow`.
*/
export async function loadDirenvEnv(
cwd: string,
opts?: { timeoutMs?: number },
): Promise<Record<string, string> | null> {
const envrcPath = await findEnvrc(cwd);
if (!envrcPath) return null;
const bin = direnvBinary();
if (!bin) return null;
let cacheKey: string;
try {
const content = await fs.readFile(envrcPath);
cacheKey = `${envrcPath}\u0000${Bun.hash(content).toString(36)}`;
} catch {
return null;
}
const cached = exportCache.get(cacheKey);
if (cached) return cached;
const dir = path.dirname(envrcPath);
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);
if (exitCode !== 0) {
logger.warn("direnv export failed", { dir, exitCode });
return null;
}
const { set } = parseDirenvExport(stdout);
exportCache.set(cacheKey, set);
return set;
} catch (err) {
logger.warn("direnv load failed", { dir, error: err instanceof Error ? err.message : String(err) });
return null;
}
}
+99
View File
@@ -0,0 +1,99 @@
import { afterEach, 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";
const tmpDirs: TempDir[] = [];
function tmp(): string {
const dir = TempDir.createSync("@pi-direnv-");
tmpDirs.push(dir);
return dir.path();
}
afterEach(async () => {
for (const dir of tmpDirs.splice(0)) await dir.remove();
});
describe("findEnvrc", () => {
it("walks up to the nearest .envrc above the start dir", async () => {
const root = tmp();
await Bun.write(path.join(root, ".envrc"), "export A=1\n");
const nested = path.join(root, "a", "b");
await fs.mkdir(nested, { recursive: true });
expect(await findEnvrc(nested)).toBe(path.join(root, ".envrc"));
});
it("prefers the nearest .envrc when monorepo dirs nest them", async () => {
const root = tmp();
await Bun.write(path.join(root, ".envrc"), "export A=1\n");
const sub = path.join(root, "pkg");
await fs.mkdir(sub, { recursive: true });
await Bun.write(path.join(sub, ".envrc"), "export B=2\n");
expect(await findEnvrc(sub)).toBe(path.join(sub, ".envrc"));
});
it("returns null when no .envrc exists up the tree", async () => {
const nested = path.join(tmp(), "x", "y");
await fs.mkdir(nested, { recursive: true });
expect(await findEnvrc(nested)).toBeNull();
});
});
describe("parseDirenvExport", () => {
it("splits set values from null unsets", () => {
const out = parseDirenvExport('{"FOO":"bar","BAZ":null,"PATH":"/x:/y"}');
expect(out.set).toEqual({ FOO: "bar", PATH: "/x:/y" });
expect(out.unset).toEqual(["BAZ"]);
});
it("treats empty / whitespace output as no diff", () => {
expect(parseDirenvExport("")).toEqual({ set: {}, unset: [] });
expect(parseDirenvExport(" \n")).toEqual({ set: {}, unset: [] });
});
});
describe("loadDirenvEnv (real direnv, auto-allow)", () => {
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);
expect(env?.DIRENV_FEATURE_TEST).toBe("loaded");
expect(env?.PATH).toContain(path.join(root, "bin"));
});
it("returns null when there is no .envrc to load", async () => {
expect(await loadDirenvEnv(tmp())).toBeNull();
});
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");
await Bun.write(path.join(root, ".envrc"), "export DIRENV_CACHE_TEST=two\n");
expect((await loadDirenvEnv(root))?.DIRENV_CACHE_TEST).toBe("two");
});
});
describe("bash executor direnv wiring (end-to-end)", () => {
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");
const result = await executeBash('printf "%s|%s" "$DIRENV_WIRE_TEST" "$OVERRIDE_ME"', {
cwd: root,
env: { OVERRIDE_ME: "fromcaller" },
});
expect(result.output).toContain("fromdirenv|fromcaller");
});
});