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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user