diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index c632057bf..50092dd86 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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", diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index fc7d7dc69..e660c9e36 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -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; diff --git a/packages/coding-agent/src/exec/direnv.ts b/packages/coding-agent/src/exec/direnv.ts new file mode 100644 index 000000000..07c941cbc --- /dev/null +++ b/packages/coding-agent/src/exec/direnv.ts @@ -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 { + 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; + /** 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 = {}; + const unset: string[] = []; + if (parsed && typeof parsed === "object") { + for (const [key, value] of Object.entries(parsed as Record)) { + 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 { + const out: Record = {}; + 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, +): 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).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>(); + +/** + * 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 | 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; + } +} diff --git a/packages/coding-agent/test/direnv.test.ts b/packages/coding-agent/test/direnv.test.ts new file mode 100644 index 000000000..bbaa4c29a --- /dev/null +++ b/packages/coding-agent/test/direnv.test.ts @@ -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"); + }); +});