mise: bridge per-directory env into agent shells via a managed .envrc (direnv) #270

Open
opened 2026-11-12 21:57:33 +01:00 by omp-deck · 0 comments
Collaborator

Problem

mise currently reaches a session only through shims. Tool versions resolve correctly per directory, but no environment variable a project declares ever reaches the agent's shell.

The cause is not process.env as such: the agent's bash never starts from live process.env. Its base environment is settings.getShellConfig().env, a boot-time, filtered snapshot of the process environ that is shared by every session in the deck process:

  • session/bash-runner.ts in the agent SDK: "the child shell starts from the (cached, filtered) spawn env — NOT from live process.env".
  • tools/bash-interactive.ts says the same for PTYs (Bun's process.env writes never reach the C environ).
  • The only per-call overlay is session identity (BashTool.#sessionEnv(): PI_SESSION_ID, PI_SESSION_FILE).

So PATH is a single image-level value (Dockerfile, the PATH=/data/.local/share/mise/shims:… line) and cwd is the only per-session input. Everything else about mise had to be designed around that.

What it costs today

  1. [env] from a project's config never applies: _.file / _.source dotenvs, [env._.python.venv] auto-venv, JAVA_HOME, GOROOT, NODE_OPTIONS, … The agent is instead told to route builds through mise exec -- / mise run (apps/server/src/mise/system-context.ts).
  2. Anything not shimmed is unreachable, because it needs a PATH entry: node_modules/.bin, cargo install output, pipx, go install.
  3. A declared-but-uninstalled tool silently falls through to a same-named system binary; the prompt has to teach mise which over command -v to compensate.
  4. Same root cause beyond mise: Settings → Environment writes land in process.env (apps/server/src/env-store.ts) and reach in-process JS only — agent shells keep the boot snapshot until restart.

Proposal

Bridge the environment through direnv, which the agent SDK already loads per directory, instead of inventing a second mechanism.

The SDK ships a complete per-directory env loader — exec/direnv.ts plus applyDirenvPreflight in exec/bash-executor.ts — with walk-up .envrc discovery, a watch-file mtime cache, a load-timeout budget clamped by the caller's command timeout, unset handling via a regex-gated unset -v prefix, and caller-env-wins merge semantics. It is on by default (bash.direnv = auto) and already feeds the one-shot, ACP and PTY backends.

Sketch:

  • Add direnv to the image.
  • Workspace provisioning (apps/server/src/mise/provision.ts / materialize.ts) also writes a deck-managed .envrc into the workspace and marks it allowed.
  • Reuse the existing managed-file conventions verbatim: MANAGED_HEADER first line, never clobber a file the deck did not write (MiseConfigConflict → workspace phase failed), append to .git/info/exclude, remove again when the feature/config is cleared.
  • The same file is placed into Forgejo checkouts of the workspace, as the pasted mise config already is.

Chosen over the alternatives because it is the flexible one: any env a project wants — mise [env], a venv, plain exports, a devenv/nix shell — arrives through one mechanism, rather than only what mise env can emit.

Why not the obvious alternatives

  • Per-workspace wrapper shell via the shellPath setting. Feasible with no SDK change and keeps today's trust boundary exactly, but the environment is static per workspace: no per-subdirectory env in a monorepo, and stale until re-provision. Env-only, no .envrc escape hatch.
  • Rewriting the bash tool input from an extension (ToolCallEventResult.input). Cheapest, but the rewritten command is what the user sees and what the permission gate reasons about, and repeated sourcing accumulates PATH in the persistent shell.
  • A shell.env layer or a mise preflight upstream in the SDK. The correct endgame and the direnv code is a ready-made template, but it is not a change in this repository.

Risks / things to decide

  • Trust is the real constraint. use mise evaluates the config found in cwd, and mise [env]._.source runs a script. A Forgejo trigger session runs in a clone of a pull-request branch, so a naive per-cwd load is code execution from an untrusted branch — precisely what the current design rejects (see the MISE_NOT_FOUND_AUTO_INSTALL=0 rationale in the Dockerfile). The .envrc must pin which config it loads (e.g. ceiling paths / explicit config filenames) rather than resolving freely, and direnv's allow list must cover only the deck-written file, never one that arrives with a checkout.
  • A checkout's own .envrc becomes live the moment direnv exists in the image, independent of this feature, since bash.direnv defaults to auto. That needs a deliberate decision (allow-list scoping, or bash.direnv = off in the deck's session settings with only the managed file re-enabled).
  • Coverage is the bash tool only. Deck git tools, routines (apps/server/src/routines/v1-runner.ts passes process.env) and the eval kernels keep the shared snapshot.
  • Cold-load cost on the first command in a workspace, and interaction between direnv's PATH and the shim directory's precedence.
  • Image size and one more binary to pin/renovate.

Acceptance

  • A workspace whose mise config declares [env] (including a _.file dotenv and an auto-venv) has those variables present in a plain bash tool call, without mise exec.
  • A tool installed into a non-shimmed location (node_modules/.bin) resolves in a plain command.
  • A pull-request checkout containing an attacker-authored .envrc or [env]._.source does not execute it.
  • The managed .envrc follows the existing managed-file contract: not clobbering a user's file, git-excluded, removed when the config is cleared.
  • docs/configuration.md (Dev tools section) and apps/server/src/mise/system-context.ts are updated — the latter currently states the [env] limitation as a fact to the agent.
## Problem mise currently reaches a session only through shims. Tool *versions* resolve correctly per directory, but no environment variable a project declares ever reaches the agent's shell. The cause is not `process.env` as such: the agent's bash never starts from live `process.env`. Its base environment is `settings.getShellConfig().env`, a boot-time, filtered snapshot of the process environ that is shared by every session in the deck process: - `session/bash-runner.ts` in the agent SDK: *"the child shell starts from the (cached, filtered) spawn env — NOT from live process.env"*. - `tools/bash-interactive.ts` says the same for PTYs (Bun's `process.env` writes never reach the C environ). - The only per-call overlay is session identity (`BashTool.#sessionEnv()`: `PI_SESSION_ID`, `PI_SESSION_FILE`). So `PATH` is a single image-level value (`Dockerfile`, the `PATH=/data/.local/share/mise/shims:…` line) and cwd is the only per-session input. Everything else about mise had to be designed around that. ### What it costs today 1. `[env]` from a project's config never applies: `_.file` / `_.source` dotenvs, `[env._.python.venv]` auto-venv, `JAVA_HOME`, `GOROOT`, `NODE_OPTIONS`, … The agent is instead told to route builds through `mise exec --` / `mise run` (`apps/server/src/mise/system-context.ts`). 2. Anything not shimmed is unreachable, because it needs a `PATH` entry: `node_modules/.bin`, `cargo install` output, `pipx`, `go install`. 3. A declared-but-uninstalled tool silently falls through to a same-named system binary; the prompt has to teach `mise which` over `command -v` to compensate. 4. Same root cause beyond mise: Settings → Environment writes land in `process.env` (`apps/server/src/env-store.ts`) and reach in-process JS only — agent shells keep the boot snapshot until restart. ## Proposal Bridge the environment through direnv, which the agent SDK already loads per directory, instead of inventing a second mechanism. The SDK ships a complete per-directory env loader — `exec/direnv.ts` plus `applyDirenvPreflight` in `exec/bash-executor.ts` — with walk-up `.envrc` discovery, a watch-file mtime cache, a load-timeout budget clamped by the caller's command timeout, `unset` handling via a regex-gated `unset -v` prefix, and caller-env-wins merge semantics. It is on by default (`bash.direnv = auto`) and already feeds the one-shot, ACP and PTY backends. Sketch: - Add `direnv` to the image. - Workspace provisioning (`apps/server/src/mise/provision.ts` / `materialize.ts`) also writes a deck-managed `.envrc` into the workspace and marks it allowed. - Reuse the existing managed-file conventions verbatim: `MANAGED_HEADER` first line, never clobber a file the deck did not write (`MiseConfigConflict` → workspace phase `failed`), append to `.git/info/exclude`, remove again when the feature/config is cleared. - The same file is placed into Forgejo checkouts of the workspace, as the pasted mise config already is. Chosen over the alternatives because it is the flexible one: any env a project wants — mise `[env]`, a venv, plain exports, a devenv/nix shell — arrives through one mechanism, rather than only what `mise env` can emit. ## Why not the obvious alternatives - **Per-workspace wrapper shell via the `shellPath` setting.** Feasible with no SDK change and keeps today's trust boundary exactly, but the environment is static per workspace: no per-subdirectory env in a monorepo, and stale until re-provision. Env-only, no `.envrc` escape hatch. - **Rewriting the bash tool input from an extension** (`ToolCallEventResult.input`). Cheapest, but the rewritten command is what the user sees and what the permission gate reasons about, and repeated sourcing accumulates `PATH` in the persistent shell. - **A `shell.env` layer or a mise preflight upstream in the SDK.** The correct endgame and the direnv code is a ready-made template, but it is not a change in this repository. ## Risks / things to decide - **Trust is the real constraint.** `use mise` evaluates the config found in cwd, and mise `[env]._.source` runs a script. A Forgejo trigger session runs in a clone of a pull-request branch, so a naive per-cwd load is code execution from an untrusted branch — precisely what the current design rejects (see the `MISE_NOT_FOUND_AUTO_INSTALL=0` rationale in the `Dockerfile`). The `.envrc` must pin which config it loads (e.g. ceiling paths / explicit config filenames) rather than resolving freely, and direnv's allow list must cover only the deck-written file, never one that arrives with a checkout. - A checkout's *own* `.envrc` becomes live the moment direnv exists in the image, independent of this feature, since `bash.direnv` defaults to `auto`. That needs a deliberate decision (allow-list scoping, or `bash.direnv = off` in the deck's session settings with only the managed file re-enabled). - Coverage is the bash tool only. Deck git tools, routines (`apps/server/src/routines/v1-runner.ts` passes `process.env`) and the eval kernels keep the shared snapshot. - Cold-load cost on the first command in a workspace, and interaction between direnv's `PATH` and the shim directory's precedence. - Image size and one more binary to pin/renovate. ## Acceptance - A workspace whose mise config declares `[env]` (including a `_.file` dotenv and an auto-venv) has those variables present in a plain `bash` tool call, without `mise exec`. - A tool installed into a non-shimmed location (`node_modules/.bin`) resolves in a plain command. - A pull-request checkout containing an attacker-authored `.envrc` or `[env]._.source` does not execute it. - The managed `.envrc` follows the existing managed-file contract: not clobbering a user's file, git-excluded, removed when the config is cleared. - `docs/configuration.md` (Dev tools section) and `apps/server/src/mise/system-context.ts` are updated — the latter currently states the `[env]` limitation as a fact to the agent.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#270
No description provided.