feat(#2693): let the operator pin the claude-code every agent runs

Agents run whatever `claude-code` the meta flake's `nixpkgs` resolves
to, and that is normally a release channel. This one package moves fast
enough that stable trails unstable by weeks — 26.05 is on 2.1.187 while
unstable carries 2.1.220 — and an agent cannot fix it for itself: it
only ever sees the single nixpkgs hive-c0re injects, so an `agent.nix`
has no other tree to reach for.

New host option `services.hyperhive.c0re.claudeCodePackage` takes the
package directly and rides the existing `hyperhiveDocs` threading path —
serveConfigJson -> HiveEnv -> render_flake — to reach each agent as
`hyperhive.claudeCodePath`. Null (the default) is today's behaviour.

What travels is the store *path*, as a plain string literal, not a flake
input: containers share the host's `/nix/store`, so the build is already
reachable inside them with its whole closure and has nothing to travel.
An input would be worse than useless — a `path:/nix/store/<pkg>` input
is re-copied as a reference-less `-source`, which strips exactly the
closure the binary needs.

The catch is that a path written into a generated flake is text, so
nothing in the container's closure keeps the binary alive. The host does
that instead, and gets it for free: the package is interpolated into
`/etc/hyperhive/serve.json`, `builtins.toJSON` preserves string context,
so the /etc entry references it and the system closure gc-roots it for
as long as that generation is the one the agents were rendered from. An
assertion pins that property, because losing the context is invisible at
eval and at deploy — it would surface only as every agent failing to
spawn `claude` whenever the next gc ran.

Container side wraps the path in a symlink farm rather than putting it
on PATH directly: `systemd.services.<name>.path` and
`environment.systemPackages` both coerce a store-path *string* through
`lib.toDerivation`, i.e. `builtins.storePath`, which pure evaluation
rejects. Interpolating the path into a builder is just text and
evaluates anywhere. `claude-code` drops out of systemPackages when a
pin is set, so there is exactly one claude in the container.

Refs #2693
This commit is contained in:
atlas 2026-07-27 13:06:22 +02:00 committed by mara
commit b08176f089
7 changed files with 281 additions and 1 deletions

View file

@ -61,6 +61,33 @@
'';
};
options.hyperhive.claudeCodePath = lib.mkOption {
type = lib.types.str;
default = "";
example = "/nix/store/-claude-code-2.1.220";
description = ''
Store path of the `claude-code` this agent runs, or `""` (the
default) to use the `claude-code` from the container's own
nixpkgs.
Set by the generated meta flake when the operator sets
`services.hyperhive.c0re.claudeCodePackage` host-side, so a hive
can run a claude built from a *different* nixpkgs than the one
its agents evaluate against a release channel can trail
unstable by weeks on this one package. It arrives as a path and
not a package because agents share the host's `/nix/store`, so
the build is already reachable here with its whole closure and
has nothing to travel.
When set, `claude` on PATH is a symlink to this path's
`bin/claude` and the container's own `claude-code` is dropped, so
there is only ever one claude in the container. Note that neither
the symlink nor anything else in the container's closure *refers*
to the target keeping it alive is the host's job, see
`services.hyperhive.c0re.claudeCodePackage`.
'';
};
config = {
assertions = [
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
@ -183,8 +210,31 @@
environment.systemPackages = [
config.hyperhive.packages.hive-metric
]
++ [
(
if config.hyperhive.claudeCodePath == "" then
pkgs.claude-code
else
# Host-pinned claude: a symlink farm around a path the
# container was handed as text. It has to be a derivation —
# `environment.systemPackages` coerces a store-path *string*
# with `toDerivation`, i.e. `builtins.storePath`, which pure
# evaluation rejects (`systemd.services.*.path` does the same,
# which is why the harness gets this via PATH like everything
# else rather than a unit-level entry). Interpolating the path
# into the builder is just text, so it evaluates anywhere.
#
# The symlink registers no store reference — the target isn't
# among this derivation's inputs, so nothing here keeps the
# binary alive. That is deliberate and it is the host's job:
# see `services.hyperhive.c0re.claudeCodePackage`.
pkgs.runCommandLocal "claude-code-pinned" { } ''
mkdir -p "$out/bin"
ln -s ${config.hyperhive.claudeCodePath}/bin/claude "$out/bin/claude"
''
)
]
++ (with pkgs; [
claude-code
bashInteractive
coreutils-full
# procps for pkill — used by the web UI's /api/cancel to SIGINT the