Skip to content

The NixOS module keys agent CLIs by provider ID, adds a fixed tools baseline, and keeps claudePackage as a deprecated alias

The module puts agent CLIs and a handful of tools on the lab unit PATH, and every spawned session inherits that PATH through the tmux server lab re-adopts across restarts (ADR-0002's KillMode=process invariant, D4). That surface was claude-shaped and threadbare. The only agent-CLI knob was claudePackage (default null) — one provider, with no way to land a second agent's binary short of the raw systemd.services.lab.path escape hatch. The tools a session assumes constantly were simply absent: no awk, tar, curl, jq, file, unzip, or patch, and ps/rg reached a session only as an accident of the nixpkgs claude-code wrapper prefixing its own PATH — so a second provider's sessions would silently lack them. And no nix on PATH, so a session could not run a project flake's devshell (nix develop / nix shell) for a per-project toolchain and instead groveled /nix/store by hand.

ADR-0026 made the provider seam provider-neutral by construction and promised a second provider (Codex, #2) is "one new implementation and zero refactor of the generic core." That promise stopped at the deploy boundary: with a single claude-shaped claudePackage, #2 would still have to reach the sandbox PATH through the escape hatch, and its sessions would still miss the accidental ps/rg. This ADR carries the seam's provider-neutrality into the module. It replaces claudePackage with a provider-keyed agentPackages attrset (claude-code and codex on by default), adds a purely additive extraPackages list, and pins a fixed, unconditional tools baseline — including nix — that every provider's sessions can assume. The module keys agent CLIs by lab provider ID — the same strings the provider registry, the DB provider column, and the API already use (internal/provider/claudecode/claudecode.go: const ID = "claude-code") — so the deploy surface and the provider surface name the same thing.

The pins, decided:

  • services.lab.agentPackages — attrsOf (nullOr package), keyed by provider ID. Default { "claude-code" = pkgs.claude-code; codex = pkgs.codex; }. Every non-null value's bin/ lands on the unit PATH. The defaults are injected per key at mkOptionDefault priority — not baked into one option default the whole attr replaces — so a host merges against them per key: agentPackages."claude-code" = null removes claude while the codex default survives untouched, and adding agentPackages.gemini = pkgs.gemini-cli keeps both defaults. claude-code is unfree in nixpkgs, so a host either allows it (nixpkgs.config.allowUnfreePredicate = p: lib.getName p == "claude-code";) or sets the key to null; the option description says so. Keying by provider ID (not an opaque list) is what makes the per-key null opt-out and the alignment with the registry/DB/API possible.
  • services.lab.extraPackages — listOf package, default [], appended to the unit PATH. Purely additive: setting it never affects agentPackages or the baseline. It is the sanctioned knob for a host-specific tool a session needs, replacing reach-arounds through systemd.services.lab.path.
  • A fixed tools baseline joins the unit PATH unconditionally — deliberately not an option. gawk, gnutar, gzip, xz, zstd, unzip, curl, jq, file, patch, procps (ps), ripgrep (rg), plus the host's config.nix.package (so a session can build a per-project toolchain from that project's flake via nix develop / nix shell). It is a contract every provider's session can assume, not a per-host choice — an optional baseline reintroduces exactly the per-host drift that left ps/rg present by accident. extraPackages is the additive knob; the baseline is the floor. Deliberately no language toolchains (node, go, python, compilers): those come from each project's flake through nix on PATH, which keeps the baseline language-neutral and small.
  • claudePackage becomes a deprecated alias. When non-null it populates agentPackages."claude-code" and emits a deprecation warning. An assertion fails eval, naming both options, when claudePackage and an explicit agentPackages."claude-code" definition are both set — explicitness detected through the module system's per-attribute definition priorities (an explicit definition beats the injected mkOptionDefault-priority default, so the assertion fires only on a genuine both-set conflict, never against the shipped default). This gives every existing deployment a warning-and-migrate path instead of a hard break at the next rebuild.
  • The nixos-module flake check stays eval-only and grows variants. It covers: the defaults land both CLIs on PATH; the per-key null opt-out; extraPackages additivity; the alias warning; the both-set assertion; baseline presence. The defaults variant asserts on package names in config.systemd.services.lab.path — it never realizes unit text containing the real agent CLIs — so the check's build closure never pulls in claude-code/codex. The full VM test (baseline tools resolving from a live session shell, nix daemon reachability) stays future work (M8).

Out of scope: the Codex AgentProvider Go implementation (#2, ready-for-human) — this ADR only puts the codex binary on the sandbox PATH and generalizes the module seam; the downstream fleet-config migration (the consuming NixOS repo swapping claudePackage for the new options and rebuilding); and live verification that the nix daemon socket is reachable from inside a spawned session — a one-time manual check at deploy.

Status

Accepted. Amends ADR-0002's module option surface: claudePackage is superseded by agentPackages + extraPackages and demoted to a deprecated alias; the unit PATH gains the fixed tools baseline and nix (config.nix.package) beside the existing git/tmux/openssh/util-linux/package; ADR-0002's KillMode=process invariant and the openssh-on-PATH requirement are unchanged. Carries ADR-0026's provider-neutral-by-construction principle across the deploy boundary and reuses the provider-ID vocabulary the registry, DB provider column, and API share (ADR-0026, ADR-0021). Relates to #2 (Codex): the codex binary is on the sandbox PATH now, so #2 lands its AgentProvider with no deploy-side change. The check is eval-only; the VM test is deferred to M8.

Considered options

  • A flat listOf package for the agent CLIs instead of a provider-keyed attrset. Rejected: a list has no per-key identity, so it loses both the null opt-out (you cannot remove one default CLI without redeclaring the whole list) and the alignment between a module key and a lab provider ID. Keying by provider ID means the deploy surface, the registry, the DB provider column, and the API all name the same provider — and a host disables one CLI with a single = null.
  • Make the tools baseline an option (or a merge-able list) rather than a fixed set. Rejected: the baseline is a contract a provider's session can assume, and an optional baseline reintroduces per-host drift — precisely the accident that left ps/rg reaching sessions only through the claude wrapper's PATH prefix, invisible to any other provider. A fixed floor plus the additive extraPackages knob gives host-specific additions without letting a host silently subtract a tool sessions depend on.
  • Remove claudePackage outright. Rejected: it is a hard break for every existing deployment at the next rebuild. An alias (populate agentPackages."claude-code", warn) plus a both-set assertion gives a clean, self-announcing migration path and costs only a few lines of module glue.
  • Put language toolchains (node, go, python, compilers) in the baseline. Rejected: per-project flakes reached through nix on PATH are the mechanism for per-project toolchains, so a session builds exactly the versions a project pins rather than whatever the baseline happened to ship. Baking language runtimes into the baseline bloats it, invites version skew against each project's flake, and picks winners; the baseline stays language-neutral.
  • Defer the codex default until #2 lands. Rejected: the module seam should be provider-complete now so #2 needs no deploy-side change — a new provider ships as a pure Go add. A non-implemented provider's binary on the sandbox PATH is inert (nothing spawns it until the AgentProvider exists), so shipping the codex default early costs nothing and removes a deploy step from #2's critical path.