From b0399ebd9f207680694b7073fd596df139dfffb5 Mon Sep 17 00:00:00 2001 From: defangdevs Date: Thu, 5 Nov 2026 21:55:41 +0000 Subject: [PATCH 1/3] fix(nix): point nixpkgs# at the box's pinned nixpkgs A bare `nix profile add nixpkgs#foo` resolved through the global flake registry, which on a Determinate box maps nixpkgs to FlakeHub's nixpkgs-weekly - a revision agent-box neither chose nor moves. On an aarch64 box that rev (fe13242) had whisper-cpp dependency-failed on Hydra (nodejs-slim), so the install compiled from source while the box's own pin had it cached. Both backends now map nixpkgs in the SYSTEM registry to the jitNixpkgs pin - the revision the harnesses and the nixpkgs cache already use, moved only by a box update. The native renderer writes /etc/nix/registry.json, keeping other entries and leaving an operator's own nixpkgs mapping alone; the NixOS module sets nix.registry.nixpkgs.to at priority 900. A user's registry, --override-flake, or an explicit flake ref still wins, which is the per-case escape hatch the shipped guide now describes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RVYuG3MWrCrYD2qy2NzErC --- bin/agentbox | 62 +++++++++++++++++++ modules/agent-box.nix | 19 ++++++ modules/agent-box.nix.in | 11 ++++ modules/src/default-agents.md | 8 +++ .../vm/etc/agent-box-guides/AGENTS.agent.md | 8 +++ .../web/etc/agent-box-guides/AGENTS.agent.md | 8 +++ tests/native/expected-modes.json | 1 + .../etc/agent-box-guides/AGENTS.agent.md | 8 +++ .../etc/agent-box-guides/AGENTS.robot.md | 8 +++ tests/native/expected/etc/nix/registry.json | 15 +++++ tests/test_agentbox.py | 54 ++++++++++++++++ 11 files changed, 202 insertions(+) create mode 100644 tests/native/expected/etc/nix/registry.json diff --git a/bin/agentbox b/bin/agentbox index 076939b2..1c95ea16 100755 --- a/bin/agentbox +++ b/bin/agentbox @@ -2189,6 +2189,7 @@ class Renderer: "[Journal]\n" "SystemMaxUse=200M\n") self.nix_conf(t) + self.nix_registry(t) def nixpkgs_cache(self, t): """Build the shared nixpkgs cache each user's first install reads. @@ -2402,6 +2403,67 @@ class Renderer: mode = 0o644 t.file(target, existing.rstrip("\n") + "\n\n" + want, mode=mode) + def nix_registry(self, t): + """Point `nixpkgs#...` at this box's pinned nixpkgs. + + Without this a bare `nix profile add nixpkgs#foo` resolves through + the GLOBAL flake registry, which on a Determinate box maps nixpkgs + to FlakeHub's nixpkgs-weekly - a revision agent-box neither chose + nor moves. On one aarch64 box that rev had whisper-cpp + dependency-failed on Hydra, so the install compiled from source, + while the box's own pin had it cached. The system registry + (/etc/nix/registry.json) outranks the global one, so naming the + jitNixpkgs pin here puts user installs on the revision the harnesses, + the nixpkgs cache and every update already agree on. + + It only outranks the GLOBAL registry. A user's own + `nix registry add nixpkgs ...` (~/.config/nix/registry.json) still + wins, as does `--override-flake nixpkgs ...` or naming any other + flake ref outright - the per-case escape hatch for a package the + pin carries at an old version. + + JSON has no comment for a generated-file header, so ownership is + decided by content: entries other than nixpkgs are always kept, and + a nixpkgs entry is replaced only when it is absent or is one this + renderer wrote (a nixos.org channel tarball). Anything else is an + operator's choice and is left alone with a note. + """ + path = self.p("/etc/nix/registry.json") + ours = {"from": {"type": "indirect", "id": "nixpkgs"}, + "to": {"type": "tarball", "url": self.spec.jit_nixpkgs}} + flakes = [] + try: + existing = path.read_text() + except OSError: + existing = None + if existing is not None: + try: + data = json.loads(existing) + flakes = list(data["flakes"]) + if data.get("version") != 2: + raise ValueError("not a version 2 registry") + except (ValueError, KeyError, TypeError) as e: + self.notes.append(f"{path}: not a registry agent-box can " + f"read ({e}) - left alone; nixpkgs# " + "keeps its global mapping") + return + kept = [] + for entry in flakes: + frm = entry.get("from", {}) if isinstance(entry, dict) else {} + if frm.get("type") != "indirect" or frm.get("id") != "nixpkgs": + kept.append(entry) + continue + to = entry.get("to") + url = str(to.get("url", "")) if isinstance(to, dict) else "" + if not url.startswith(("https://releases.nixos.org/", + "https://channels.nixos.org/")): + self.notes.append(f"{path}: maps nixpkgs itself - left " + "alone; agent-box did not pin it") + return + t.dir(self.p("/etc/nix"), 0o755) + t.file(path, json.dumps({"version": 2, "flakes": [ours] + kept}, + indent=2) + "\n") + # -- base-OS patching ---------------------------------------------------- def os_updates(self, t): diff --git a/modules/agent-box.nix b/modules/agent-box.nix index ddfec43c..6e2f8d5a 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -1309,6 +1309,14 @@ let - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). + `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses + come from, moved only by a box update - so installs hit the binary cache + and every session gets the same versions. When the pin carries a package + at an old version, override it for that one install: + `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the + project's own flake (`nix profile add github:OWNER/REPO`). Run + `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change + it for your user; `nix registry remove nixpkgs` goes back to the pin. - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with @@ -20437,6 +20445,17 @@ in # List settings merge, so hosts can still extend this. nix.settings.experimental-features = [ "nix-command" "flakes" ]; + # ... and `nixpkgs#` resolves to the box's own pin, the one the harnesses + # and the nixpkgs cache use, instead of whatever the global registry + # follows that week (a rev Hydra may not have built). This is the system + # registry, so a user's `nix registry add nixpkgs ...` or a per-command + # `--override-flake nixpkgs ...` still wins. Above nixpkgs' own mkDefault + # (the system flake) and below a host's plain definition. + nix.registry.nixpkgs.to = lib.mkOverride 900 { + type = "tarball"; + url = jitNixpkgsRef; + }; + # Trust DefangLabs' public Cachix cache, so agent-box-defang-cli.service # below substitutes a prebuilt closure instead of compiling Go from # source (agent-box#373). This alone would NOT fix first boot if diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index 2f6568c6..f57d7243 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -3153,6 +3153,17 @@ in # List settings merge, so hosts can still extend this. nix.settings.experimental-features = [ "nix-command" "flakes" ]; + # ... and `nixpkgs#` resolves to the box's own pin, the one the harnesses + # and the nixpkgs cache use, instead of whatever the global registry + # follows that week (a rev Hydra may not have built). This is the system + # registry, so a user's `nix registry add nixpkgs ...` or a per-command + # `--override-flake nixpkgs ...` still wins. Above nixpkgs' own mkDefault + # (the system flake) and below a host's plain definition. + nix.registry.nixpkgs.to = lib.mkOverride 900 { + type = "tarball"; + url = jitNixpkgsRef; + }; + # Trust DefangLabs' public Cachix cache, so agent-box-defang-cli.service # below substitutes a prebuilt closure instead of compiling Go from # source (agent-box#373). This alone would NOT fix first boot if diff --git a/modules/src/default-agents.md b/modules/src/default-agents.md index 95f39795..3569c3ef 100644 --- a/modules/src/default-agents.md +++ b/modules/src/default-agents.md @@ -94,6 +94,14 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). + `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses + come from, moved only by a box update - so installs hit the binary cache + and every session gets the same versions. When the pin carries a package + at an old version, override it for that one install: + `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the + project's own flake (`nix profile add github:OWNER/REPO`). Run + `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change + it for your user; `nix registry remove nixpkgs` goes back to the pin. - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with diff --git a/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md b/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md index b3e67064..aa6f162e 100644 --- a/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md @@ -109,6 +109,14 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). + `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses + come from, moved only by a box update - so installs hit the binary cache + and every session gets the same versions. When the pin carries a package + at an old version, override it for that one install: + `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the + project's own flake (`nix profile add github:OWNER/REPO`). Run + `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change + it for your user; `nix registry remove nixpkgs` goes back to the pin. - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with diff --git a/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md b/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md index 8b47fefc..0a0c9575 100644 --- a/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md @@ -109,6 +109,14 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). + `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses + come from, moved only by a box update - so installs hit the binary cache + and every session gets the same versions. When the pin carries a package + at an old version, override it for that one install: + `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the + project's own flake (`nix profile add github:OWNER/REPO`). Run + `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change + it for your user; `nix registry remove nixpkgs` goes back to the pin. - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with diff --git a/tests/native/expected-modes.json b/tests/native/expected-modes.json index 71aef2bf..dd44752c 100644 --- a/tests/native/expected-modes.json +++ b/tests/native/expected-modes.json @@ -33,6 +33,7 @@ "etc/gitconfig": "0o644", "etc/needrestart/conf.d/50-agent-box.conf": "0o644", "etc/nix/nix.custom.conf": "0o644", + "etc/nix/registry.json": "0o644", "etc/polkit-1/rules.d/50-agent-box-caddy-reload.rules": "0o644", "etc/profile.d/agent-box-tls.sh": "0o644", "etc/sudoers.d/agent-box": "0o440", diff --git a/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md b/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md index 2e19ab08..a452ab74 100644 --- a/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md @@ -146,6 +146,14 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). + `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses + come from, moved only by a box update - so installs hit the binary cache + and every session gets the same versions. When the pin carries a package + at an old version, override it for that one install: + `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the + project's own flake (`nix profile add github:OWNER/REPO`). Run + `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change + it for your user; `nix registry remove nixpkgs` goes back to the pin. - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with diff --git a/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md b/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md index 2e19ab08..a452ab74 100644 --- a/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md +++ b/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md @@ -146,6 +146,14 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). + `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses + come from, moved only by a box update - so installs hit the binary cache + and every session gets the same versions. When the pin carries a package + at an old version, override it for that one install: + `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the + project's own flake (`nix profile add github:OWNER/REPO`). Run + `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change + it for your user; `nix registry remove nixpkgs` goes back to the pin. - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with diff --git a/tests/native/expected/etc/nix/registry.json b/tests/native/expected/etc/nix/registry.json new file mode 100644 index 00000000..c728c5eb --- /dev/null +++ b/tests/native/expected/etc/nix/registry.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "flakes": [ + { + "from": { + "type": "indirect", + "id": "nixpkgs" + }, + "to": { + "type": "tarball", + "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" + } + } + ] +} diff --git a/tests/test_agentbox.py b/tests/test_agentbox.py index 15cbc468..5ab40dd5 100644 --- a/tests/test_agentbox.py +++ b/tests/test_agentbox.py @@ -824,6 +824,60 @@ def test_the_store_has_a_janitor(self): self.assertIn("min-free = 1073741824", nixconf) self.assertIn("max-free = 5368709120", nixconf) + def test_nixpkgs_registry_follows_the_box_pin(self): + """`nixpkgs#` must name the jitNixpkgs pin, not the global + registry's moving target, so a user install lands on the revision + the harnesses use and that the binary cache has built.""" + reg = json.loads((FIXTURE / "etc/nix/registry.json").read_text()) + self.assertEqual(2, reg["version"]) + entry = reg["flakes"][0] + self.assertEqual({"type": "indirect", "id": "nixpkgs"}, entry["from"]) + self.assertEqual("tarball", entry["to"]["type"]) + # The fixture renders with no pin file under its root, so the pin + # is the channel default - the same URL jitNixpkgs falls back to. + self.assertEqual(load_agentbox().CHANNEL_NIXPKGS, entry["to"]["url"]) + + def _render_registry(self, existing): + with tempfile.TemporaryDirectory() as tmp: + prof = build_fake_profile(tmp) + out = Path(tmp) / "out" + (out / "etc/nix").mkdir(parents=True) + (out / "etc/nix/registry.json").write_text(existing) + mod = load_agentbox() + spec_obj = mod.Spec(json.loads(CONFIG_JSON.read_text()), prof) + rend = mod.Renderer(spec_obj, prof, root=out) + tree = rend.render() + written = tree.files.get(str(out / "etc/nix/registry.json")) + return (json.loads(written[0]) if written else None), rend.notes + + def test_a_foreign_registry_keeps_its_other_entries(self): + other = {"from": {"type": "indirect", "id": "mine"}, + "to": {"type": "github", "owner": "o", "repo": "r"}} + stale = {"from": {"type": "indirect", "id": "nixpkgs"}, + "to": {"type": "tarball", "url": "https://releases.nixos.org" + "/nixos/unstable/old/nixexprs.tar.xz"}} + reg, _ = self._render_registry( + json.dumps({"version": 2, "flakes": [other, stale]})) + self.assertIn(other, reg["flakes"], "dropped somebody else's entry") + pins = [f for f in reg["flakes"] if f["from"].get("id") == "nixpkgs"] + self.assertEqual(1, len(pins), "left the stale pin beside the new one") + self.assertNotEqual(stale["to"], pins[0]["to"]) + + def test_an_operator_nixpkgs_mapping_is_left_alone(self): + theirs = {"version": 2, "flakes": [ + {"from": {"type": "indirect", "id": "nixpkgs"}, + "to": {"type": "github", "owner": "NixOS", "repo": "nixpkgs", + "ref": "nixos-25.11"}}]} + reg, notes = self._render_registry(json.dumps(theirs)) + self.assertIsNone(reg, "overruled an operator's own nixpkgs mapping") + self.assertTrue(any("registry.json" in n for n in notes), + "skipped the pin without saying so") + + def test_an_unreadable_registry_is_left_alone(self): + reg, notes = self._render_registry("not json") + self.assertIsNone(reg) + self.assertTrue(any("registry.json" in n for n in notes)) + def test_a_foreign_nix_custom_conf_keeps_its_scalars(self): """/etc/nix/nix.custom.conf is what Determinate tells a HUMAN to edit, so a value somebody set there is not ours to overrule. From 06cfd71650e37e9b65576a908e117e0101191c5c Mon Sep 17 00:00:00 2001 From: defangdevs Date: Thu, 5 Nov 2026 22:09:58 +0000 Subject: [PATCH 2/3] fix(nix): own only our registry entry, and keep it safe to rewrite Review follow-up on the nixpkgs# registry pin: - Ownership is a record (/etc/agent-box/nix-registry-nixpkgs.json), not the URL's host: an operator's nixpkgs mapping is never overruled, even on releases.nixos.org, and a pin agent-box wrote still moves when the pin does, whatever host it names. - A foreign registry keeps its mode and its symlink, as nix.custom.conf already does, and write_if_changed creates its temp file at the target mode so a 0600 file is never briefly world-readable. - An unreadable, undecodable or non-v2 registry, and malformed entries, are left alone or kept instead of aborting apply. - Only an https archive jitNixpkgs maps to a registry entry, on both backends; a github:/git+/path ref leaves nixpkgs# where it was. - A rolled-back update moves our registry entry back with the pin file, since the restored release may predate the registry. - Guide and module comment: the revision is fixed once a pin exists, and `nix registry remove` returns to the box's mapping, not always the pin. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RVYuG3MWrCrYD2qy2NzErC --- bin/agentbox | 178 +++++++++++++---- modules/agent-box.nix | 40 ++-- modules/agent-box.nix.in | 28 ++- modules/src/default-agents.md | 12 +- .../vm/etc/agent-box-guides/AGENTS.agent.md | 12 +- .../web/etc/agent-box-guides/AGENTS.agent.md | 12 +- tests/native/expected-modes.json | 1 + .../etc/agent-box-guides/AGENTS.agent.md | 12 +- .../etc/agent-box-guides/AGENTS.robot.md | 12 +- .../etc/agent-box/nix-registry-nixpkgs.json | 1 + tests/test_agentbox.py | 182 +++++++++++++++--- 11 files changed, 377 insertions(+), 113 deletions(-) create mode 100644 tests/native/expected/etc/agent-box/nix-registry-nixpkgs.json diff --git a/bin/agentbox b/bin/agentbox index 1c95ea16..f7fa9b00 100755 --- a/bin/agentbox +++ b/bin/agentbox @@ -152,6 +152,11 @@ HARNESS_UPGRADE_UNIT = "agent-box-harness-upgrade@" # did, which is why a native box resolved the channel ONCE, at the first # install, and stayed there for ever (issues #559, #590, #614). AGENT_PIN_FILE = "/etc/agent-box/agent-nixpkgs-pin" +# The system flake registry, and agent-box's record of the nixpkgs mapping +# it last wrote there. JSON has no comment for a generated-file header, so +# the record is what tells our entry from an operator's (nix_registry). +NIX_REGISTRY = "/etc/nix/registry.json" +NIX_REGISTRY_STAMP = "/etc/agent-box/nix-registry-nixpkgs.json" # The mutable channel the pin is resolved FROM when nothing overrides it. CHANNEL_NIXPKGS = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" # Where the capped newuidmap/newgidmap copies go. This exact path is on @@ -2422,47 +2427,20 @@ class Renderer: flake ref outright - the per-case escape hatch for a package the pin carries at an old version. - JSON has no comment for a generated-file header, so ownership is - decided by content: entries other than nixpkgs are always kept, and - a nixpkgs entry is replaced only when it is absent or is one this - renderer wrote (a nixos.org channel tarball). Anything else is an - operator's choice and is left alone with a note. + The file is shared, so only our own entry is ours: see + plan_nix_registry for the ownership rule, and nix_conf for why a + foreign file keeps its mode and its symlink. """ - path = self.p("/etc/nix/registry.json") - ours = {"from": {"type": "indirect", "id": "nixpkgs"}, - "to": {"type": "tarball", "url": self.spec.jit_nixpkgs}} - flakes = [] - try: - existing = path.read_text() - except OSError: - existing = None - if existing is not None: - try: - data = json.loads(existing) - flakes = list(data["flakes"]) - if data.get("version") != 2: - raise ValueError("not a version 2 registry") - except (ValueError, KeyError, TypeError) as e: - self.notes.append(f"{path}: not a registry agent-box can " - f"read ({e}) - left alone; nixpkgs# " - "keeps its global mapping") - return - kept = [] - for entry in flakes: - frm = entry.get("from", {}) if isinstance(entry, dict) else {} - if frm.get("type") != "indirect" or frm.get("id") != "nixpkgs": - kept.append(entry) - continue - to = entry.get("to") - url = str(to.get("url", "")) if isinstance(to, dict) else "" - if not url.startswith(("https://releases.nixos.org/", - "https://channels.nixos.org/")): - self.notes.append(f"{path}: maps nixpkgs itself - left " - "alone; agent-box did not pin it") - return + path = self.p(NIX_REGISTRY) + stamp = self.p(NIX_REGISTRY_STAMP) + plan = plan_nix_registry(path, stamp, self.spec.jit_nixpkgs) + if plan.note: + self.notes.append(plan.note) + if plan.text is None: + return t.dir(self.p("/etc/nix"), 0o755) - t.file(path, json.dumps({"version": 2, "flakes": [ours] + kept}, - indent=2) + "\n") + t.file(plan.target, plan.text, mode=plan.mode) + t.file(stamp, plan.stamp) # -- base-OS patching ---------------------------------------------------- @@ -4121,7 +4099,14 @@ def write_if_changed(path, text, mode, dry_run=False): return True path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(path.name + ".agentbox-new") - tmp.write_text(text) + # Created at the TARGET mode, not chmod'ed to it after the write: a + # 0600 file (a registry or a nix.custom.conf carrying credentials) + # must not exist world-readable even for the moment in between. + with contextlib.suppress(FileNotFoundError): + tmp.unlink() + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode & 0o777) + with os.fdopen(fd, "w") as f: + f.write(text) os.chmod(tmp, mode) os.replace(tmp, path) print(f"wrote {path}") @@ -5569,6 +5554,117 @@ def recover_handover(args, profile, generation, target, rc): return rc +class RegistryPlan: + """What nix_registry would write, and where, and why not if it won't.""" + + def __init__(self, text=None, target=None, mode=0o644, stamp=None, + note=None): + self.text, self.target, self.mode = text, target, mode + self.stamp, self.note = stamp, note + + +def registry_target(ref): + """The registry `to` for a jitNixpkgs ref, or None. + + Only an https archive URL - which is what the channel default and every + pin `agentbox update` writes look like - maps to a registry entry + without parsing nix's whole flake-ref grammar here. Anything else + (github:, git+, a path) leaves nixpkgs# where it was rather than + guessing a type nix would then refuse. + """ + bare = ref.split("?", 1)[0] + if ref.startswith("https://") and re.search( + r"\.(tar(\.[a-z0-9]+)?|tgz|zip)$", bare): + return {"type": "tarball", "url": ref} + return None + + +def plan_nix_registry(path, stamp_path, ref): + """Plan /etc/nix/registry.json so nixpkgs maps to `ref`. + + Entries other than nixpkgs are always kept. A nixpkgs entry is replaced + only when it is absent, or when it is exactly the mapping agent-box + recorded writing last time (NIX_REGISTRY_STAMP) - so an operator's own + mapping, whatever host it names, is never overruled, and a pin agent-box + wrote is still moved when the pin moves. A file that cannot be read or + parsed is left alone. Every skip is a note: a silently skipped setting + looks exactly like one never implemented. + """ + path = Path(path) + want = registry_target(ref) + if want is None: + return RegistryPlan(note=f"{path}: jitNixpkgs ({ref}) is not an " + "https archive URL - nixpkgs# keeps its " + "existing mapping") + ours = {"from": {"type": "indirect", "id": "nixpkgs"}, "to": want} + try: + recorded = json.loads(Path(stamp_path).read_text()) + except (OSError, ValueError): + recorded = None + flakes, mode = [], 0o644 + target = path.resolve() if path.is_symlink() else path + try: + existing = path.read_text() + mode = stat.S_IMODE(path.stat().st_mode) + except FileNotFoundError: + existing = None + except (OSError, UnicodeDecodeError) as e: + return RegistryPlan(note=f"{path}: could not be read ({e}) - left " + "unchanged") + if existing is not None: + try: + data = json.loads(existing) + if not isinstance(data, dict) or data.get("version") != 2: + raise ValueError("not a version 2 registry") + flakes = list(data["flakes"]) + except (ValueError, KeyError, TypeError) as e: + return RegistryPlan(note=f"{path}: not a registry agent-box can " + f"read ({e}) - left unchanged") + kept = [] + for entry in flakes: + frm = entry.get("from") if isinstance(entry, dict) else None + if not (isinstance(frm, dict) and frm.get("type") == "indirect" + and frm.get("id") == "nixpkgs"): + kept.append(entry) + continue + # Ours if it is what we recorded writing - or already exactly what + # we would write, which needs no record to be safe to restate. + if entry.get("to") not in (recorded, want): + return RegistryPlan(note=f"{path}: maps nixpkgs itself - left " + "alone; agent-box did not pin it") + text = json.dumps({"version": 2, "flakes": [ours] + kept}, + indent=2) + "\n" + if existing == text and recorded == want: + return RegistryPlan() + return RegistryPlan(text=text, target=target, mode=mode, + stamp=json.dumps(want) + "\n") + + +def retarget_nix_registry(from_ref, to_ref): + """Move our nixpkgs registry entry from `from_ref` back to `to_ref`. + + For an update that rolled back: the restored release may predate the + registry and so never re-render it, which would leave nixpkgs# on the + pin the failed release wrote. Only moves an entry that is still ours AND + still names `from_ref`; an operator's jitNixpkgs never moved, so its + entry is left where it is. Best effort, like the pin restore beside it. + """ + if registry_target(from_ref) is None: + return + try: + recorded = json.loads(Path(NIX_REGISTRY_STAMP).read_text()) + except (OSError, ValueError): + return + if recorded != registry_target(from_ref): + return + plan = plan_nix_registry(NIX_REGISTRY, NIX_REGISTRY_STAMP, to_ref) + if plan.text is None: + return + with contextlib.suppress(OSError): + write_if_changed(plan.target, plan.text, plan.mode) + write_if_changed(NIX_REGISTRY_STAMP, plan.stamp, 0o644) + + def read_agent_pin(root="/"): """This box's recorded agent-nixpkgs pin, or None before its first update. @@ -5706,6 +5802,8 @@ def post_switch(args, profile): Path(AGENT_PIN_FILE).unlink(missing_ok=True) else: Path(AGENT_PIN_FILE).write_text(previous_pin) + retarget_nix_registry( + resolved_pin, (previous_pin or "").strip() or CHANNEL_NIXPKGS) # Re-apply with the rolled-back code, as a fresh process for the # same version-skew reason phase one handed over to us. run([str(profile / "bin" / "agentbox"), "apply", diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 6e2f8d5a..4b7ada1d 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -1309,14 +1309,16 @@ let - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses - come from, moved only by a box update - so installs hit the binary cache - and every session gets the same versions. When the pin carries a package - at an old version, override it for that one install: + `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come + from. After the box's first update that is a fixed revision, moved only + by the next update; before it, the nixos-unstable channel. Installs hit + the binary cache and every session gets the same versions. When it + carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the project's own flake (`nix profile add github:OWNER/REPO`). Run `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change - it for your user; `nix registry remove nixpkgs` goes back to the pin. + it for your user; `nix registry remove nixpkgs` goes back to the box's + mapping (`nix registry list` shows it - an operator may have set their own). - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with @@ -20445,16 +20447,24 @@ in # List settings merge, so hosts can still extend this. nix.settings.experimental-features = [ "nix-command" "flakes" ]; - # ... and `nixpkgs#` resolves to the box's own pin, the one the harnesses - # and the nixpkgs cache use, instead of whatever the global registry - # follows that week (a rev Hydra may not have built). This is the system - # registry, so a user's `nix registry add nixpkgs ...` or a per-command - # `--override-flake nixpkgs ...` still wins. Above nixpkgs' own mkDefault - # (the system flake) and below a host's plain definition. - nix.registry.nixpkgs.to = lib.mkOverride 900 { - type = "tarball"; - url = jitNixpkgsRef; - }; + # ... and `nixpkgs#` resolves to the same nixpkgs the harnesses and the + # nixpkgs cache use, instead of whatever the global registry follows + # that week (a rev Hydra may not have built). That is a fixed revision + # once selfUpdate.agentNixpkgs or jitNixpkgs pins one; with neither it + # is the nixos-unstable channel, which moves with the channel. This is + # the system registry, so a user's `nix registry add nixpkgs ...` or a + # per-command `--override-flake nixpkgs ...` still wins. Above nixpkgs' + # own mkDefault (the system flake) and below a host's plain definition. + # Only an https archive ref maps to a typed registry entry without + # parsing the whole flake-ref grammar; any other jitNixpkgs (github:, + # git+, a path) leaves the registry as nixpkgs set it, as on native. + nix.registry.nixpkgs.to = lib.mkIf + (builtins.match "https://.*[.](tar([.][a-z0-9]+)?|tgz|zip)([?].*)?" + jitNixpkgsRef != null) + (lib.mkOverride 900 { + type = "tarball"; + url = jitNixpkgsRef; + }); # Trust DefangLabs' public Cachix cache, so agent-box-defang-cli.service # below substitutes a prebuilt closure instead of compiling Go from diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index f57d7243..9f333773 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -3153,16 +3153,24 @@ in # List settings merge, so hosts can still extend this. nix.settings.experimental-features = [ "nix-command" "flakes" ]; - # ... and `nixpkgs#` resolves to the box's own pin, the one the harnesses - # and the nixpkgs cache use, instead of whatever the global registry - # follows that week (a rev Hydra may not have built). This is the system - # registry, so a user's `nix registry add nixpkgs ...` or a per-command - # `--override-flake nixpkgs ...` still wins. Above nixpkgs' own mkDefault - # (the system flake) and below a host's plain definition. - nix.registry.nixpkgs.to = lib.mkOverride 900 { - type = "tarball"; - url = jitNixpkgsRef; - }; + # ... and `nixpkgs#` resolves to the same nixpkgs the harnesses and the + # nixpkgs cache use, instead of whatever the global registry follows + # that week (a rev Hydra may not have built). That is a fixed revision + # once selfUpdate.agentNixpkgs or jitNixpkgs pins one; with neither it + # is the nixos-unstable channel, which moves with the channel. This is + # the system registry, so a user's `nix registry add nixpkgs ...` or a + # per-command `--override-flake nixpkgs ...` still wins. Above nixpkgs' + # own mkDefault (the system flake) and below a host's plain definition. + # Only an https archive ref maps to a typed registry entry without + # parsing the whole flake-ref grammar; any other jitNixpkgs (github:, + # git+, a path) leaves the registry as nixpkgs set it, as on native. + nix.registry.nixpkgs.to = lib.mkIf + (builtins.match "https://.*[.](tar([.][a-z0-9]+)?|tgz|zip)([?].*)?" + jitNixpkgsRef != null) + (lib.mkOverride 900 { + type = "tarball"; + url = jitNixpkgsRef; + }); # Trust DefangLabs' public Cachix cache, so agent-box-defang-cli.service # below substitutes a prebuilt closure instead of compiling Go from diff --git a/modules/src/default-agents.md b/modules/src/default-agents.md index 3569c3ef..0bf961d0 100644 --- a/modules/src/default-agents.md +++ b/modules/src/default-agents.md @@ -94,14 +94,16 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses - come from, moved only by a box update - so installs hit the binary cache - and every session gets the same versions. When the pin carries a package - at an old version, override it for that one install: + `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come + from. After the box's first update that is a fixed revision, moved only + by the next update; before it, the nixos-unstable channel. Installs hit + the binary cache and every session gets the same versions. When it + carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the project's own flake (`nix profile add github:OWNER/REPO`). Run `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change - it for your user; `nix registry remove nixpkgs` goes back to the pin. + it for your user; `nix registry remove nixpkgs` goes back to the box's + mapping (`nix registry list` shows it - an operator may have set their own). - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with diff --git a/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md b/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md index aa6f162e..84de6110 100644 --- a/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md @@ -109,14 +109,16 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses - come from, moved only by a box update - so installs hit the binary cache - and every session gets the same versions. When the pin carries a package - at an old version, override it for that one install: + `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come + from. After the box's first update that is a fixed revision, moved only + by the next update; before it, the nixos-unstable channel. Installs hit + the binary cache and every session gets the same versions. When it + carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the project's own flake (`nix profile add github:OWNER/REPO`). Run `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change - it for your user; `nix registry remove nixpkgs` goes back to the pin. + it for your user; `nix registry remove nixpkgs` goes back to the box's + mapping (`nix registry list` shows it - an operator may have set their own). - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with diff --git a/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md b/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md index 0a0c9575..04ec91c2 100644 --- a/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md @@ -109,14 +109,16 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses - come from, moved only by a box update - so installs hit the binary cache - and every session gets the same versions. When the pin carries a package - at an old version, override it for that one install: + `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come + from. After the box's first update that is a fixed revision, moved only + by the next update; before it, the nixos-unstable channel. Installs hit + the binary cache and every session gets the same versions. When it + carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the project's own flake (`nix profile add github:OWNER/REPO`). Run `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change - it for your user; `nix registry remove nixpkgs` goes back to the pin. + it for your user; `nix registry remove nixpkgs` goes back to the box's + mapping (`nix registry list` shows it - an operator may have set their own). - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with diff --git a/tests/native/expected-modes.json b/tests/native/expected-modes.json index dd44752c..5652db94 100644 --- a/tests/native/expected-modes.json +++ b/tests/native/expected-modes.json @@ -14,6 +14,7 @@ "etc/agent-box/fail2ban/jail.conf": "0o644", "etc/agent-box/guides/agent-agents-pointer.md": "0o644", "etc/agent-box/guides/robot-agents-pointer.md": "0o644", + "etc/agent-box/nix-registry-nixpkgs.json": "0o644", "etc/agent-box/seed/agent-sessions.json": "0o644", "etc/agent-box/seed/robot-sessions.json": "0o644", "etc/agent-box/session-limit": "0o644", diff --git a/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md b/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md index a452ab74..593c49ce 100644 --- a/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md @@ -146,14 +146,16 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses - come from, moved only by a box update - so installs hit the binary cache - and every session gets the same versions. When the pin carries a package - at an old version, override it for that one install: + `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come + from. After the box's first update that is a fixed revision, moved only + by the next update; before it, the nixos-unstable channel. Installs hit + the binary cache and every session gets the same versions. When it + carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the project's own flake (`nix profile add github:OWNER/REPO`). Run `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change - it for your user; `nix registry remove nixpkgs` goes back to the pin. + it for your user; `nix registry remove nixpkgs` goes back to the box's + mapping (`nix registry list` shows it - an operator may have set their own). - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with diff --git a/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md b/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md index a452ab74..593c49ce 100644 --- a/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md +++ b/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md @@ -146,14 +146,16 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is this box's pinned nixpkgs - the same revision the harnesses - come from, moved only by a box update - so installs hit the binary cache - and every session gets the same versions. When the pin carries a package - at an old version, override it for that one install: + `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come + from. After the box's first update that is a fixed revision, moved only + by the next update; before it, the nixos-unstable channel. Installs hit + the binary cache and every session gets the same versions. When it + carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the project's own flake (`nix profile add github:OWNER/REPO`). Run `nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-unstable` to change - it for your user; `nix registry remove nixpkgs` goes back to the pin. + it for your user; `nix registry remove nixpkgs` goes back to the box's + mapping (`nix registry list` shows it - an operator may have set their own). - Your config lives in ~/.config/agent-box/. Secrets and environment variables go in the file `env` there (KEY=value, one per line; blank lines and `#` comment lines are ignored, so annotate freely). Set them with diff --git a/tests/native/expected/etc/agent-box/nix-registry-nixpkgs.json b/tests/native/expected/etc/agent-box/nix-registry-nixpkgs.json new file mode 100644 index 00000000..774864c3 --- /dev/null +++ b/tests/native/expected/etc/agent-box/nix-registry-nixpkgs.json @@ -0,0 +1 @@ +{"type": "tarball", "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"} diff --git a/tests/test_agentbox.py b/tests/test_agentbox.py index 5ab40dd5..db501f79 100644 --- a/tests/test_agentbox.py +++ b/tests/test_agentbox.py @@ -24,6 +24,7 @@ import os import re import shutil +import stat import subprocess import sys import tempfile @@ -837,47 +838,182 @@ def test_nixpkgs_registry_follows_the_box_pin(self): # is the channel default - the same URL jitNixpkgs falls back to. self.assertEqual(load_agentbox().CHANNEL_NIXPKGS, entry["to"]["url"]) - def _render_registry(self, existing): + def _render_registry(self, existing=None, stamp=None, config=None, + prepare=None): + """Render against a root holding `existing` as the registry and + `stamp` as agent-box's record of its last write. Returns the parsed + registry it would write (None for none), the notes, the out root + and the tree.""" with tempfile.TemporaryDirectory() as tmp: prof = build_fake_profile(tmp) out = Path(tmp) / "out" (out / "etc/nix").mkdir(parents=True) - (out / "etc/nix/registry.json").write_text(existing) + (out / "etc/agent-box").mkdir(parents=True) + reg = out / "etc/nix/registry.json" + if existing is not None: + (reg.write_bytes if isinstance(existing, bytes) + else reg.write_text)(existing) + if stamp is not None: + (out / "etc/agent-box/nix-registry-nixpkgs.json").write_text( + json.dumps(stamp)) + if prepare: + prepare(out) mod = load_agentbox() - spec_obj = mod.Spec(json.loads(CONFIG_JSON.read_text()), prof) - rend = mod.Renderer(spec_obj, prof, root=out) + data = json.loads(CONFIG_JSON.read_text()) + data.update(config or {}) + rend = mod.Renderer(mod.Spec(data, prof), prof, root=out) tree = rend.render() - written = tree.files.get(str(out / "etc/nix/registry.json")) - return (json.loads(written[0]) if written else None), rend.notes + target = reg.resolve() if reg.is_symlink() else reg + written = tree.files.get(str(target)) + return ((json.loads(written[0]) if written else None), + rend.notes, written, tree, out) + + @staticmethod + def _pin(url): + return {"from": {"type": "indirect", "id": "nixpkgs"}, + "to": {"type": "tarball", "url": url}} - def test_a_foreign_registry_keeps_its_other_entries(self): + OLD = ("https://releases.nixos.org/nixos/unstable/old/nixexprs.tar.xz") + + def test_a_registry_keeps_other_entries_and_moves_our_pin(self): other = {"from": {"type": "indirect", "id": "mine"}, "to": {"type": "github", "owner": "o", "repo": "r"}} - stale = {"from": {"type": "indirect", "id": "nixpkgs"}, - "to": {"type": "tarball", "url": "https://releases.nixos.org" - "/nixos/unstable/old/nixexprs.tar.xz"}} - reg, _ = self._render_registry( - json.dumps({"version": 2, "flakes": [other, stale]})) + stale = self._pin(self.OLD) + reg, *_ = self._render_registry( + json.dumps({"version": 2, "flakes": [other, stale]}), + stamp=stale["to"]) self.assertIn(other, reg["flakes"], "dropped somebody else's entry") pins = [f for f in reg["flakes"] if f["from"].get("id") == "nixpkgs"] self.assertEqual(1, len(pins), "left the stale pin beside the new one") self.assertNotEqual(stale["to"], pins[0]["to"]) - def test_an_operator_nixpkgs_mapping_is_left_alone(self): - theirs = {"version": 2, "flakes": [ - {"from": {"type": "indirect", "id": "nixpkgs"}, - "to": {"type": "github", "owner": "NixOS", "repo": "nixpkgs", - "ref": "nixos-25.11"}}]} - reg, notes = self._render_registry(json.dumps(theirs)) - self.assertIsNone(reg, "overruled an operator's own nixpkgs mapping") - self.assertTrue(any("registry.json" in n for n in notes), - "skipped the pin without saying so") + def test_an_explicit_jit_pin_reaches_the_registry(self): + """A renderer that hardcoded the channel URL would pass the fixture + test; this one would not.""" + url = "https://example.org/nixpkgs/pinned.tar.gz" + reg, *_ = self._render_registry(config={"jitNixpkgs": url}) + self.assertEqual({"type": "tarball", "url": url}, + reg["flakes"][0]["to"]) + + def test_a_pin_agent_box_wrote_moves_whatever_its_host(self): + """Ownership is the record, not the URL's host: pin A on a host that + is not nixos.org is still ours, and moves to pin B.""" + a = "https://example.org/a.tar.gz" + b = "https://example.org/b.tar.gz" + reg, *_ = self._render_registry( + json.dumps({"version": 2, "flakes": [self._pin(a)]}), + stamp={"type": "tarball", "url": a}, config={"jitNixpkgs": b}) + self.assertEqual(b, reg["flakes"][0]["to"]["url"]) - def test_an_unreadable_registry_is_left_alone(self): - reg, notes = self._render_registry("not json") + def test_an_operator_nixpkgs_mapping_is_left_alone(self): + """Even one on releases.nixos.org: with no record that agent-box + wrote it, it is an operator's choice.""" + for to in ({"type": "github", "owner": "NixOS", "repo": "nixpkgs", + "ref": "nixos-25.11"}, + {"type": "tarball", "url": self.OLD}): + with self.subTest(to=to): + theirs = {"version": 2, "flakes": [ + {"from": {"type": "indirect", "id": "nixpkgs"}, + "to": to}]} + reg, notes, *_ = self._render_registry(json.dumps(theirs)) + self.assertIsNone(reg, "overruled an operator's mapping") + self.assertTrue(any("registry.json" in n for n in notes), + "skipped the pin without saying so") + + def test_an_operator_edit_after_our_write_is_left_alone(self): + mine = {"type": "tarball", "url": self.OLD} + theirs = {"type": "github", "owner": "NixOS", "repo": "nixpkgs"} + reg, notes, *_ = self._render_registry( + json.dumps({"version": 2, "flakes": [ + {"from": {"type": "indirect", "id": "nixpkgs"}, + "to": theirs}]}), stamp=mine) self.assertIsNone(reg) self.assertTrue(any("registry.json" in n for n in notes)) + def test_an_unreadable_registry_is_left_alone(self): + for bad in ("not json", b"\xff\xfe not utf-8", + json.dumps({"version": 1, "flakes": []})): + with self.subTest(bad=bad): + reg, notes, *_ = self._render_registry(bad) + self.assertIsNone(reg) + self.assertTrue(any("registry.json" in n for n in notes)) + + def test_a_malformed_entry_is_kept_not_fatal(self): + odd = {"from": None, "to": None} + reg, *_ = self._render_registry( + json.dumps({"version": 2, "flakes": [odd, "junk"]})) + self.assertIn(odd, reg["flakes"]) + self.assertIn("junk", reg["flakes"]) + + def test_a_non_tarball_jit_ref_leaves_the_registry(self): + reg, notes, *_ = self._render_registry( + config={"jitNixpkgs": "github:NixOS/nixpkgs/nixos-unstable"}) + self.assertIsNone(reg, "wrote a github: ref as a tarball URL") + self.assertTrue(any("not an https archive" in n for n in notes)) + + def test_a_registry_keeps_its_mode_and_symlink(self): + def prepare(out): + real = out / "elsewhere.json" + real.write_text(json.dumps({"version": 2, "flakes": []})) + real.chmod(0o600) + reg = out / "etc/nix/registry.json" + reg.unlink(missing_ok=True) + reg.symlink_to(real) + reg, _, written, tree, out = self._render_registry(prepare=prepare) + self.assertIsNotNone(reg) + self.assertEqual(0o600, written[1], "widened a root-only registry") + self.assertNotIn(str(out / "etc/nix/registry.json"), tree.files, + "would replace the symlink with a plain file") + + def test_a_settled_registry_is_not_rewritten(self): + reg, _, _, tree, out = self._render_registry() + stamp = json.loads(tree.files[ + str(out / "etc/agent-box/nix-registry-nixpkgs.json")][0]) + again, notes, *_ = self._render_registry( + json.dumps(reg, indent=2) + "\n", stamp=stamp) + self.assertIsNone(again, "rewrote a settled registry") + self.assertFalse([n for n in notes if "registry.json" in n]) + + def test_write_if_changed_never_widens_a_temp_file(self): + """The temp file is CREATED at the target mode, so a 0600 file is + never readable by others, not even between the write and a chmod.""" + mod = load_agentbox() + modes = [] + real_open = os.open + + def spy(path, flags, mode=0o777, *a, **k): + modes.append(mode) + return real_open(path, flags, mode, *a, **k) + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "secret.json" + with mock.patch.object(mod.os, "open", spy): + mod.write_if_changed(target, "x\n", 0o600) + self.assertEqual(0o600, stat.S_IMODE(target.stat().st_mode)) + self.assertEqual([0o600], modes) + + def test_rollback_puts_our_registry_pin_back(self): + """A rolled-back update restores the pin file and re-applies with + the OLD release, which may predate the registry - so the registry + is moved back too, but only off the pin the failed run wrote.""" + mod = load_agentbox() + new_pin = "https://releases.nixos.org/nixos/unstable/new/nixexprs.tar.xz" + with tempfile.TemporaryDirectory() as tmp: + reg = Path(tmp) / "registry.json" + stamp = Path(tmp) / "stamp.json" + reg.write_text(json.dumps( + {"version": 2, "flakes": [self._pin(new_pin)]})) + stamp.write_text(json.dumps(self._pin(new_pin)["to"])) + with mock.patch.object(mod, "NIX_REGISTRY", str(reg)), \ + mock.patch.object(mod, "NIX_REGISTRY_STAMP", str(stamp)): + # Not ours to move: the registry names some other ref. + mod.retarget_nix_registry("https://x.org/other.tar.gz", + self.OLD) + self.assertIn(new_pin, reg.read_text()) + mod.retarget_nix_registry(new_pin, self.OLD) + back = json.loads(reg.read_text()) + self.assertEqual(self.OLD, back["flakes"][0]["to"]["url"]) + self.assertEqual(self.OLD, json.loads(stamp.read_text())["url"]) + def test_a_foreign_nix_custom_conf_keeps_its_scalars(self): """/etc/nix/nix.custom.conf is what Determinate tells a HUMAN to edit, so a value somebody set there is not ours to overrule. From ef35688535537753027ac8ede6daa186f99d6869 Mon Sep 17 00:00:00 2001 From: defangdevs Date: Fri, 6 Nov 2026 00:21:38 +0000 Subject: [PATCH 3/3] fix(nix): keep a host's nixpkgs flake, enforce narHash/dir, qualify the guide CodeRabbit review on #1003: an explicit nix.registry.nixpkgs.flake is no longer overridden; narHash and dir move from the tarball URL into registry attributes (NixOS and native); the guide says to run nix registry list when the mapping is left alone. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RVYuG47JMijTMa3xEDMwCJ --- bin/agentbox | 22 +++++++--- modules/agent-box.nix | 44 +++++++++++++++---- modules/agent-box.nix.in | 35 ++++++++++++--- modules/src/default-agents.md | 9 ++-- .../vm/etc/agent-box-guides/AGENTS.agent.md | 9 ++-- .../web/etc/agent-box-guides/AGENTS.agent.md | 9 ++-- .../etc/agent-box-guides/AGENTS.agent.md | 9 ++-- .../etc/agent-box-guides/AGENTS.robot.md | 9 ++-- tests/test_agentbox.py | 9 ++++ 9 files changed, 122 insertions(+), 33 deletions(-) diff --git a/bin/agentbox b/bin/agentbox index f7fa9b00..854e3b55 100755 --- a/bin/agentbox +++ b/bin/agentbox @@ -52,6 +52,7 @@ import sys import tempfile import time import urllib.error +import urllib.parse import urllib.request import uuid from pathlib import Path @@ -5572,11 +5573,22 @@ def registry_target(ref): (github:, git+, a path) leaves nixpkgs# where it was rather than guessing a type nix would then refuse. """ - bare = ref.split("?", 1)[0] - if ref.startswith("https://") and re.search( - r"\.(tar(\.[a-z0-9]+)?|tgz|zip)$", bare): - return {"type": "tarball", "url": ref} - return None + bare, _, query = ref.partition("?") + if not (ref.startswith("https://") and re.search( + r"\.(tar(\.[a-z0-9]+)?|tgz|zip)$", bare)): + return None + # narHash and dir are flake attributes, not part of the download URL: + # left in `url`, nix would not enforce the hash and would drop the + # subdirectory. Every other query parameter stays on the URL. + want, rest = {"type": "tarball"}, [] + for param in query.split("&") if query else []: + key, _, value = param.partition("=") + if key in ("narHash", "dir"): + want[key] = urllib.parse.unquote(value) + else: + rest.append(param) + want["url"] = bare + ("?" + "&".join(rest) if rest else "") + return want def plan_nix_registry(path, stamp_path, ref): diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 4b7ada1d..b68ae187 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -1309,9 +1309,12 @@ let - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come - from. After the box's first update that is a fixed revision, moved only - by the next update; before it, the nixos-unstable channel. Installs hit + When the registry maps `nixpkgs` to the box's nixpkgs, `nixpkgs#` is the + same one the harnesses come from. After the box's first update that is a + fixed revision, moved only by the next update; before it, the + nixos-unstable channel. If `jitNixpkgs` is not an https archive, or an + operator mapped `nixpkgs` themselves, agent-box leaves the mapping alone: + run `nix registry list` to see what `nixpkgs#` resolves to. Installs hit the binary cache and every session gets the same versions. When it carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the @@ -5739,6 +5742,29 @@ done else if cfg.selfUpdate.agentNixpkgs != null then cfg.selfUpdate.agentNixpkgs.url else "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; + # The registry `to` for jitNixpkgsRef (an https archive; see the registry + # block below). narHash and dir are flake attributes, so they leave the + # URL; any other query parameter stays on it. + jitNixpkgsRegistryTarget = + let + unescape = builtins.replaceStrings + [ "%2B" "%2b" "%2F" "%2f" "%3D" "%3d" ] [ "+" "+" "/" "/" "=" "=" ]; + parts = lib.splitString "?" jitNixpkgsRef; + base = builtins.head parts; + params = lib.filter (p: p != "") + (lib.splitString "&" (lib.concatStringsSep "?" (builtins.tail parts))); + isFlakeParam = p: lib.hasPrefix "narHash=" p || lib.hasPrefix "dir=" p; + flakeAttrs = lib.listToAttrs (map + (p: + let kv = lib.splitString "=" p; + in lib.nameValuePair (builtins.head kv) + (unescape (lib.concatStringsSep "=" (builtins.tail kv)))) + (lib.filter isFlakeParam params)); + rest = lib.filter (p: !(isFlakeParam p)) params; + in + { type = "tarball"; url = base + lib.optionalString (rest != [ ]) ("?" + lib.concatStringsSep "&" rest); } + // flakeAttrs; + agentPackage = agent: if cfg.package != null then cfg.package else if agent == "claude" then agentPkgs.claude-code @@ -20458,13 +20484,15 @@ in # Only an https archive ref maps to a typed registry entry without # parsing the whole flake-ref grammar; any other jitNixpkgs (github:, # git+, a path) leaves the registry as nixpkgs set it, as on native. + # + # An explicit `nix.registry.nixpkgs.flake` is the host's own choice, so + # it is left to derive `to`. narHash and dir move from the URL query to + # attributes, so nix enforces the hash and keeps the subdirectory. nix.registry.nixpkgs.to = lib.mkIf (builtins.match "https://.*[.](tar([.][a-z0-9]+)?|tgz|zip)([?].*)?" - jitNixpkgsRef != null) - (lib.mkOverride 900 { - type = "tarball"; - url = jitNixpkgsRef; - }); + jitNixpkgsRef != null + && config.nix.registry.nixpkgs.flake == null) + (lib.mkOverride 900 jitNixpkgsRegistryTarget); # Trust DefangLabs' public Cachix cache, so agent-box-defang-cli.service # below substitutes a prebuilt closure instead of compiling Go from diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index 9f333773..7fb60234 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -662,6 +662,29 @@ let else if cfg.selfUpdate.agentNixpkgs != null then cfg.selfUpdate.agentNixpkgs.url else "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; + # The registry `to` for jitNixpkgsRef (an https archive; see the registry + # block below). narHash and dir are flake attributes, so they leave the + # URL; any other query parameter stays on it. + jitNixpkgsRegistryTarget = + let + unescape = builtins.replaceStrings + [ "%2B" "%2b" "%2F" "%2f" "%3D" "%3d" ] [ "+" "+" "/" "/" "=" "=" ]; + parts = lib.splitString "?" jitNixpkgsRef; + base = builtins.head parts; + params = lib.filter (p: p != "") + (lib.splitString "&" (lib.concatStringsSep "?" (builtins.tail parts))); + isFlakeParam = p: lib.hasPrefix "narHash=" p || lib.hasPrefix "dir=" p; + flakeAttrs = lib.listToAttrs (map + (p: + let kv = lib.splitString "=" p; + in lib.nameValuePair (builtins.head kv) + (unescape (lib.concatStringsSep "=" (builtins.tail kv)))) + (lib.filter isFlakeParam params)); + rest = lib.filter (p: !(isFlakeParam p)) params; + in + { type = "tarball"; url = base + lib.optionalString (rest != [ ]) ("?" + lib.concatStringsSep "&" rest); } + // flakeAttrs; + agentPackage = agent: if cfg.package != null then cfg.package else if agent == "claude" then agentPkgs.claude-code @@ -3164,13 +3187,15 @@ in # Only an https archive ref maps to a typed registry entry without # parsing the whole flake-ref grammar; any other jitNixpkgs (github:, # git+, a path) leaves the registry as nixpkgs set it, as on native. + # + # An explicit `nix.registry.nixpkgs.flake` is the host's own choice, so + # it is left to derive `to`. narHash and dir move from the URL query to + # attributes, so nix enforces the hash and keeps the subdirectory. nix.registry.nixpkgs.to = lib.mkIf (builtins.match "https://.*[.](tar([.][a-z0-9]+)?|tgz|zip)([?].*)?" - jitNixpkgsRef != null) - (lib.mkOverride 900 { - type = "tarball"; - url = jitNixpkgsRef; - }); + jitNixpkgsRef != null + && config.nix.registry.nixpkgs.flake == null) + (lib.mkOverride 900 jitNixpkgsRegistryTarget); # Trust DefangLabs' public Cachix cache, so agent-box-defang-cli.service # below substitutes a prebuilt closure instead of compiling Go from diff --git a/modules/src/default-agents.md b/modules/src/default-agents.md index 0bf961d0..08cd6702 100644 --- a/modules/src/default-agents.md +++ b/modules/src/default-agents.md @@ -94,9 +94,12 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come - from. After the box's first update that is a fixed revision, moved only - by the next update; before it, the nixos-unstable channel. Installs hit + When the registry maps `nixpkgs` to the box's nixpkgs, `nixpkgs#` is the + same one the harnesses come from. After the box's first update that is a + fixed revision, moved only by the next update; before it, the + nixos-unstable channel. If `jitNixpkgs` is not an https archive, or an + operator mapped `nixpkgs` themselves, agent-box leaves the mapping alone: + run `nix registry list` to see what `nixpkgs#` resolves to. Installs hit the binary cache and every session gets the same versions. When it carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the diff --git a/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md b/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md index 84de6110..b91a7731 100644 --- a/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/golden/vm/etc/agent-box-guides/AGENTS.agent.md @@ -109,9 +109,12 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come - from. After the box's first update that is a fixed revision, moved only - by the next update; before it, the nixos-unstable channel. Installs hit + When the registry maps `nixpkgs` to the box's nixpkgs, `nixpkgs#` is the + same one the harnesses come from. After the box's first update that is a + fixed revision, moved only by the next update; before it, the + nixos-unstable channel. If `jitNixpkgs` is not an https archive, or an + operator mapped `nixpkgs` themselves, agent-box leaves the mapping alone: + run `nix registry list` to see what `nixpkgs#` resolves to. Installs hit the binary cache and every session gets the same versions. When it carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the diff --git a/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md b/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md index 04ec91c2..6271cd36 100644 --- a/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/golden/web/etc/agent-box-guides/AGENTS.agent.md @@ -109,9 +109,12 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come - from. After the box's first update that is a fixed revision, moved only - by the next update; before it, the nixos-unstable channel. Installs hit + When the registry maps `nixpkgs` to the box's nixpkgs, `nixpkgs#` is the + same one the harnesses come from. After the box's first update that is a + fixed revision, moved only by the next update; before it, the + nixos-unstable channel. If `jitNixpkgs` is not an https archive, or an + operator mapped `nixpkgs` themselves, agent-box leaves the mapping alone: + run `nix registry list` to see what `nixpkgs#` resolves to. Installs hit the binary cache and every session gets the same versions. When it carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the diff --git a/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md b/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md index 593c49ce..41fd0daa 100644 --- a/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md +++ b/tests/native/expected/etc/agent-box-guides/AGENTS.agent.md @@ -146,9 +146,12 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come - from. After the box's first update that is a fixed revision, moved only - by the next update; before it, the nixos-unstable channel. Installs hit + When the registry maps `nixpkgs` to the box's nixpkgs, `nixpkgs#` is the + same one the harnesses come from. After the box's first update that is a + fixed revision, moved only by the next update; before it, the + nixos-unstable channel. If `jitNixpkgs` is not an https archive, or an + operator mapped `nixpkgs` themselves, agent-box leaves the mapping alone: + run `nix registry list` to see what `nixpkgs#` resolves to. Installs hit the binary cache and every session gets the same versions. When it carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the diff --git a/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md b/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md index 593c49ce..41fd0daa 100644 --- a/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md +++ b/tests/native/expected/etc/agent-box-guides/AGENTS.robot.md @@ -146,9 +146,12 @@ plainly rather than handing it back. - Install extra tools with nix, e.g. `nix profile add nixpkgs#awscli2` (no sudo needed; tools land in ~/.nix-profile/bin, already on PATH). - `nixpkgs#` is the box's own nixpkgs - the same one the harnesses come - from. After the box's first update that is a fixed revision, moved only - by the next update; before it, the nixos-unstable channel. Installs hit + When the registry maps `nixpkgs` to the box's nixpkgs, `nixpkgs#` is the + same one the harnesses come from. After the box's first update that is a + fixed revision, moved only by the next update; before it, the + nixos-unstable channel. If `jitNixpkgs` is not an https archive, or an + operator mapped `nixpkgs` themselves, agent-box leaves the mapping alone: + run `nix registry list` to see what `nixpkgs#` resolves to. Installs hit the binary cache and every session gets the same versions. When it carries a package at an old version, override it for that one install: `nix profile add github:NixOS/nixpkgs/nixpkgs-unstable#pulumi`, or the diff --git a/tests/test_agentbox.py b/tests/test_agentbox.py index db501f79..fb33f499 100644 --- a/tests/test_agentbox.py +++ b/tests/test_agentbox.py @@ -951,6 +951,15 @@ def test_a_non_tarball_jit_ref_leaves_the_registry(self): self.assertIsNone(reg, "wrote a github: ref as a tarball URL") self.assertTrue(any("not an https archive" in n for n in notes)) + def test_flake_params_leave_the_url_for_attributes(self): + reg, *_ = self._render_registry(config={"jitNixpkgs": ( + "https://example.org/n.tar.xz" + "?narHash=sha256-a%2Bb%2Fc%3D&x=1&dir=sub")}) + self.assertEqual( + {"type": "tarball", "url": "https://example.org/n.tar.xz?x=1", + "narHash": "sha256-a+b/c=", "dir": "sub"}, + reg["flakes"][0]["to"]) + def test_a_registry_keeps_its_mode_and_symlink(self): def prepare(out): real = out / "elsewhere.json"