From e1557d48b1cd06fcebd3a59bcfae7011f83adb5d Mon Sep 17 00:00:00 2001 From: defangdevs Date: Mon, 31 Aug 2026 22:30:59 +0000 Subject: [PATCH 1/2] fix(native): turning the web off has to take the listener down (#413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bin/agentbox`'s render is additive: `apply` deletes only what a render explicitly queues, so a section that stops emitting files leaves the previous apply's files — and its UNITS — in place. `caddy()` and `fail2ban()` were called under `if self.spec.web_enable:` with no else, and the webhook artifacts had no teardown at all. The per-user %i instances did stop (cmd_apply walks those prefixes). What survived were the PLAIN units nothing reconciles: `caddy.service` went on listening on 80/443, with the browser terminal's basic auth in front of an upstream that no longer existed, `agent-box-fail2ban.service` went on watching a log nobody wrote, and `agent-web-auth-secrets.service` went on projecting the terminal's secrets into /run. Same defect class as codexFullAccess (#394) and protectMemory (#404), with a public listener on the other end. `web_off()` and `webhook_off()` follow `memory_protection_off()`: queue the files, append the plain units to `t.disable`, and gate the disable on the unit still being ours so a box that never had the feature stays quiet and an administrator's own copy is left alone. Ordering is the reverse of startup — the jail (`PartOf=caddy.service`) first, the secrets caddy `Requires=` last — so nothing is briefly running without what it depends on. Two things had to be solved to make removal actually possible: **Ownership for files that cannot carry a header.** `remove_if_ours` proves ownership by the generated header. The %i template units are installed byte-for-byte from the profile and shared with the NixOS backend, where our header would be false; the `web-users` manifest is a TSV whose own reader would parse a header line as a user. Both now pass a `verbatim` witness — the exact text this box would have written — which is strictly NARROWER than the header check, not wider: an edited file no longer matches and is left alone, with the same message. There is a test for exactly that. **Headers where one belongs and was simply missing.** The per-user env files, the per-instance drop-ins, the three `/usr/local/bin` wrappers and the rendered Caddyfile carried no header (the wrappers carried a near-miss, "# Generated by `agentbox apply`." without the "— do not edit"), so none of them was ever removable. All four formats take `#` comments; they carry the real header now. That also makes the three wrappers removable for the first time, which they were not before this change either. Rather than enumerate what `caddy()` and `fail2ban()` write — a list that goes stale the moment someone adds a file and has no reason to look here — `would_have_written()` runs those same methods into a throwaway Tree and takes their `files`. It is guarded: a box that cannot reconstruct (a fragment that grew an unknown token) still gets its units stopped and says in a note what it could not remove. Deliberately NOT removed, and said so in the code: `/etc/agent-box/.hash` and the cookie secrets under `/var/lib/agent-box-web`. This renderer neither mints nor can re-derive them, so turning the terminal off keeps the password for turning it back on; nothing reads them with caddy down. Likewise the fail2ban `action.d` symlink, which points into the profile and is inert once the jail is. ## Tests Three cases, all in the shape #404 established — two applies into ONE root, which is the only way to see this (separate roots cannot): - `test_turning_web_off_takes_the_public_listener_with_it`: 13 rendered paths gone, and the three plain units named for disable in dependency order. - `test_turning_webhooks_off_removes_the_cli_that_promises_them`. - `test_an_edited_web_file_survives_the_teardown`: the `verbatim` witness refuses an administrator's edit to both a header-less manifest and a template unit, and says "no longer ours to remove". Verified as real guards: each fails on master's renderer. ## Checks run (aarch64) `agentbox-render`, `backend-parity`, `one-spec-both-backends`, `module-generated-up-to-date`, `golden-snapshot` — pass. `nix build .#packages.aarch64-linux.runtime` (the flake8 gate over bin/agentbox) — pass. `python3 tests/test_agentbox.py` — 73 tests, OK. Native-only: no module change, no `modules/` or `tests/golden/` movement, no new unit, no new sudo grant, no AWS/Azure template change. Existing boxes pick it up at their next `agentbox update`. Closes #413. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PBDHCBttvBEHQJJH24MfxF --- bin/agentbox | 222 ++++++++++++++++-- tests/native/expected/etc/agent-box/Caddyfile | 1 + .../units/agent-box-settings-agent.env | 1 + .../units/agent-box-settings-robot.env | 1 + .../units/agent-box-webhook-agent.env | 1 + .../units/agent-box-webhook-robot.env | 1 + .../units/agent-web-terminal-agent.env | 1 + .../units/agent-web-terminal-robot.env | 1 + .../expected/etc/agent-box/units/agent.env | 1 + .../expected/etc/agent-box/units/robot.env | 1 + .../10-host.conf | 1 + .../10-host.conf | 1 + .../10-host.conf | 1 + .../10-host.conf | 1 + .../agent-box@agent.service.d/10-host.conf | 1 + .../agent-box@robot.service.d/10-host.conf | 1 + .../10-host.conf | 1 + .../10-host.conf | 1 + .../expected/usr/local/bin/agent-box-profile | 2 +- .../expected/usr/local/bin/agent-box-session | 2 +- .../expected/usr/local/bin/agent-box-webhook | 2 +- tests/test_agentbox.py | 149 ++++++++++++ 22 files changed, 372 insertions(+), 22 deletions(-) diff --git a/bin/agentbox b/bin/agentbox index 3c30013f..ad50b09d 100755 --- a/bin/agentbox +++ b/bin/agentbox @@ -571,8 +571,16 @@ class Tree: self.files = {} # path -> (text, mode) self.dirs = [] # (path, mode, owner, group) self.links = [] # (link, target) - # Paths a PREVIOUS render owned and this one does not. apply - # deletes only files still carrying our generated header. + # Paths a PREVIOUS render owned and this one does not, as + # (path, verbatim). apply deletes only files it can still prove are + # ours: normally that is the generated header, but some things we + # render carry no comment syntax to put one in (the web-users TSV) or + # are installed byte-for-byte from the profile and shared with the + # NixOS backend, where our header would be a lie (the %i template + # units). For those, `verbatim` is the exact text this box would have + # written; a file that still matches it byte for byte is as + # provably-ours as a header, and one that does not is somebody's edit + # and stays. None means header-only. self.remove = [] self.units = [] # unit names to enable self.disable = [] # unit names to disable @@ -586,17 +594,32 @@ class Tree: def link(self, link, target): self.links.append((str(link), str(target))) - def gone(self, path): - self.remove.append(str(path)) + def gone(self, path, verbatim=None): + self.remove.append((str(path), verbatim)) def env_file(pairs): - """Render an EnvironmentFile. Keys sorted for a stable diff.""" - return "".join(f"{k}={v}\n" for k, v in pairs if v not in (None, "")) + """Render an EnvironmentFile. Keys sorted for a stable diff. + + Carries the generated header — systemd's EnvironmentFile syntax takes + `#` comments, and without one apply could not prove these files were + still its own, so the per-user env of a feature that got turned off + stayed on disk forever (issue #413). It also tells an administrator + reading /etc/agent-box/units/ who writes the file. + """ + return GENERATED_HEADER + "".join( + f"{k}={v}\n" for k, v in pairs if v not in (None, "")) def dropin(section_lines): - return "[Unit]\n\n[Service]\n" + "".join(f"{l}\n" for l in section_lines) + """A per-instance drop-in, with the generated header. + + Same reason env_file carries one: without it apply cannot prove the + drop-in is still its own, so the host config of a feature that got + turned off outlived the feature (issue #413). + """ + return (GENERATED_HEADER + "[Unit]\n\n[Service]\n" + + "".join(f"{l}\n" for l in section_lines)) class Renderer: @@ -646,6 +669,10 @@ class Renderer: if self.spec.web_enable: self.caddy(t) self.fail2ban(t) + else: + self.web_off(t) + if not self.spec.webhook_enable: + self.webhook_off(t) return t # -- agent defaults ------------------------------------------------------ @@ -967,6 +994,142 @@ class Renderer: ])) t.units.append(FAIL2BAN_UNIT) + # -- turning the web off ------------------------------------------------- + + def would_have_written(self, *methods): + """The files a render with the feature ON would have produced. + + Enumerating them by hand is how the list goes stale: the next person + to add a file to caddy() has no reason to look here, and a forgotten + entry is invisible (the file simply survives). So ask the renderer + instead — run the same methods into a throwaway Tree and take their + `files`. Nothing is written; only `.files` is read, so the dirs, + links and units those methods also collect are discarded. + + Guarded, because this runs on a box that may never have had the + feature: caddy() reads the Caddyfile fragments out of the profile + and raises if one grew a token it does not know. A box that cannot + reconstruct what it would have written still gets the units stopped + (that half needs no reconstruction) and says what it could not do. + """ + scratch = Tree() + try: + for method in methods: + method(scratch) + except (ConfigError, OSError) as exc: + self.notes.append( + f"could not reconstruct what the disabled feature would have " + f"rendered ({exc}) — its units are stopped and disabled, but " + f"its files are left in place; remove them by hand") + return {} + return scratch.files + + def web_off(self, t): + """web.enable: false has to take the box OFF the public internet. + + `apply` never deletes what a render stops emitting, and caddy, + fail2ban and agent-web-auth-secrets are PLAIN units, so nothing + reconciled them: the per-user %i instances stopped (cmd_apply walks + those prefixes) and caddy went on listening on 80/443 with the + terminal's basic auth in front of an upstream that no longer + existed, while the jail went on watching a log nobody wrote. Same + defect as codexFullAccess (#394) and protectMemory (#404), with a + public listener on the other end — issue #413. + + Order matters, and it is the reverse of how they come up. fail2ban + is `PartOf=caddy.service`, so stopping caddy would stop it anyway; + disabling it first means it is never briefly jailing a service that + is on its way down. agent-web-auth-secrets goes last because caddy + `Requires=` it: dropping the dependency before the dependant leaves + systemd pulling a unit we just disabled back in. + """ + for unit in (FAIL2BAN_UNIT, "caddy.service", + "agent-web-auth-secrets.service"): + path = self.p("/etc/systemd/system", unit) + # caddy.service and agent-web-auth-secrets.service are rendered + # from methods on this class, so their text is reproducible; the + # fail2ban unit carries the header and needs no witness. + verbatim = { + "caddy.service": self.caddy_unit, + "agent-web-auth-secrets.service": self.auth_secrets_unit, + }.get(unit) + verbatim = verbatim() if verbatim else None + t.gone(path, verbatim) + if generated_by_us(path) or ( + verbatim is not None and self.same_text(path, verbatim)): + t.disable.append(unit) + # The %i templates for the two web families, installed byte-for-byte + # from the profile (shared_units) — so the profile's own copy is the + # witness that they are still ours. + self.gone_shared_units(t, ("agent-web-terminal@", "agent-box-settings@")) + for u in self.spec.users: + for name in (f"agent-web-terminal-{u.name}.env", + f"agent-box-settings-{u.name}.env"): + t.gone(self.p("/etc/agent-box/units", name)) + for unit in (f"agent-web-terminal@{u.name}.service", + f"agent-box-settings@{u.name}.service"): + t.gone(self.p("/etc/systemd/system", f"{unit}.d", + "10-host.conf")) + # The manifest carries no comment syntax its reader tolerates + # (read_web_users would parse a header line as a user), so it is + # removed on the byte-identical witness instead. + t.gone(self.p(WEB_USERS_FILE), self.web_users_text()) + for path, (text, _mode) in self.would_have_written( + self.caddy, self.fail2ban).items(): + t.gone(path, text) + # NOT removed: /etc/agent-box/.hash and the cookie secrets + # under /var/lib/agent-box-web. They are credentials this renderer + # does not mint and cannot re-derive — `apply --first-boot` and the + # password helper own them — so turning the terminal off keeps the + # password for turning it back on. Nothing reads them while caddy is + # down. Same for the action.d symlink under the fail2ban dir: a + # symlink is never "ours" (generated_by_us refuses one deliberately), + # and it points into the profile, so it is inert once the jail is. + + def webhook_off(self, t): + """webhook.enable: false, the same shape, smaller. + + The %i instances are stopped by cmd_apply's prefix walk; what + survived was the CLI wrapper telling an agent to subscribe to a + receiver that is not listening, plus the templates and env files. + """ + self.gone_shared_units(t, ("agent-box-webhook@",)) + for u in self.spec.users: + t.gone(self.p("/etc/agent-box/units", + f"agent-box-webhook-{u.name}.env")) + t.gone(self.p("/etc/systemd/system", + f"agent-box-webhook@{u.name}.service.d", + "10-host.conf")) + t.gone(self.p("/usr/local/bin/agent-box-webhook")) + + def gone_shared_units(self, t, prefixes): + """Queue the profile-installed %i templates matching `prefixes`. + + Witnessed by the profile's own copy: `shared_units` writes these + byte-for-byte, so an unmodified file on disk is still exactly what + we put there. They cannot carry the generated header — the same + bytes are the NixOS backend's units, where "generated by agentbox + apply" would be false. + """ + src = self.share / "units" + if not src.is_dir(): + return + for unit in sorted(src.iterdir()): + if unit.name.startswith(tuple(prefixes)): + t.gone(self.p("/etc/systemd/system", unit.name), + unit.read_text()) + + def web_users_text(self): + return "".join(f"{u.name}\t/etc/agent-box/{u.name}.hash\n" + for u in self.spec.users) + + @staticmethod + def same_text(path, text): + try: + return Path(path).read_text() == text + except (OSError, UnicodeDecodeError): + return False + # -- store housekeeping ------------------------------------------------- def nix_gc(self, t): @@ -1435,10 +1598,9 @@ class Renderer: # The manifest that replaces the module's eval-time per-user loop # (design item 5 in issue #154): who has a web login, and where # their password hash lives. - t.file(self.p("/etc/agent-box/web-users"), - "".join(f"{u.name}\t/etc/agent-box/{u.name}.hash\n" - for u in self.spec.users), - 0o600) + # web_users_text, not a second copy: web_off has to reproduce + # this file byte for byte to prove it is still ours to remove. + t.file(self.p(WEB_USERS_FILE), self.web_users_text(), 0o600) def user_env(self, t, u): agent_bins = " ".join( @@ -1943,7 +2105,7 @@ class Renderer: q = shlex.quote t.file(self.p("/usr/local/bin/agent-box-session"), "#!/bin/sh\n" - "# Generated by `agentbox apply`.\n" + "# Generated by `agentbox apply` — do not edit.\n" f"export AGENT_BOX_AGENTS={q(agents)}\n" f"export AGENT_BOX_DEFAULT_AGENT={q(self.spec.default_agent)}\n" f"export AGENT_BOX_FLOCK_BIN={q(f'{self.bin}/flock')}\n" @@ -1966,7 +2128,7 @@ class Renderer: # the same one parser. t.file(self.p("/usr/local/bin/agent-box-profile"), "#!/bin/sh\n" - "# Generated by `agentbox apply`.\n" + "# Generated by `agentbox apply` — do not edit.\n" f"export AGENT_BOX_AGENTS={q(agents)}\n" f"export AGENT_BOX_DEFAULT_AGENT={q(self.spec.default_agent)}\n" f"export AGENT_BOX_ENVSTORE_BIN=" @@ -2023,7 +2185,13 @@ class Renderer: parts.append(bind("terminal", u)) parts.append(bind("root", root_user)) parts.append("}\n") - t.file(self.p("/etc/agent-box/Caddyfile"), "\n".join(parts), 0o644) + # GENERATED_HEADER first: the fragment's own banner is shared with + # the module and speaks NixOS ("the next nixos-rebuild"), so it is + # not a marker of who wrote THIS file. Without a header of ours, + # web_off could not prove the Caddyfile was still ours to remove and + # a disabled terminal left its vhost config on disk. + t.file(self.p("/etc/agent-box/Caddyfile"), + GENERATED_HEADER + "\n".join(parts), 0o644) # The per-user password helper. It crosses sudo, so its paths are # compiled in here rather than read from env — an env-supplied path @@ -2067,7 +2235,7 @@ class Renderer: if self.spec.webhook_enable: t.file(self.p("/usr/local/bin/agent-box-webhook"), "#!/bin/sh\n" - "# Generated by `agentbox apply`.\n" + "# Generated by `agentbox apply` — do not edit.\n" f"export AGENT_BOX_WEBHOOK_SCRIPT=" f"{shlex.quote(self.spec.webhook_script)}\n" f"export AGENT_BOX_HOOK_SESSION_ARGS=" @@ -2176,7 +2344,7 @@ def generated_by_us(path): return text.startswith(GENERATED_HEADER) -def remove_if_ours(path, dry_run=False): +def remove_if_ours(path, dry_run=False, verbatim=None): """Delete a file a previous render owned and this one no longer emits. Only ours: the file has to still carry the generated header, so a @@ -2184,9 +2352,22 @@ def remove_if_ours(path, dry_run=False): which is the point — a renderer that removes files it did not write is a footgun, and one that never removes anything leaves stale policy in force (issue #394: codexFullAccess flipped off). + + `verbatim` is the second way to prove ownership, for the files that + cannot carry the header: the caller passes the exact text this box would + have written, and a byte-identical file on disk is ours by construction. + It is strictly narrower than the header check, not wider — an edited file + no longer matches, so it is left alone for the same reason and with the + same message. """ path = Path(path) - if not generated_by_us(path): + ours = generated_by_us(path) + if not ours and verbatim is not None and not path.is_symlink(): + try: + ours = path.read_text() == verbatim + except (OSError, UnicodeDecodeError): + ours = False + if not ours: if path.exists(): print(f"note: {path} is no longer ours to remove — left alone") return False @@ -2293,8 +2474,11 @@ def cmd_apply(args): if link_if_changed(link, target, args.dry_run): changed += 1 removed = set() - for path in sorted(tree.remove): - if remove_if_ours(path, args.dry_run): + # By path only: two entries for the same path (one witnessed, one + # not) are not orderable against each other, and the order of the + # witnesses would not mean anything if they were. + for path, verbatim in sorted(tree.remove, key=lambda e: e[0]): + if remove_if_ours(path, args.dry_run, verbatim): removed.add(path) changed += 1 diff --git a/tests/native/expected/etc/agent-box/Caddyfile b/tests/native/expected/etc/agent-box/Caddyfile index 043b8bad..b2f72ff5 100644 --- a/tests/native/expected/etc/agent-box/Caddyfile +++ b/tests/native/expected/etc/agent-box/Caddyfile @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. # This file is module-managed by services.agent-box — edits here get # OVERWRITTEN on the next nixos-rebuild. To add your own virtual host, # drop a *.caddy snippet into ~/sites/ (which is a symlink into diff --git a/tests/native/expected/etc/agent-box/units/agent-box-settings-agent.env b/tests/native/expected/etc/agent-box/units/agent-box-settings-agent.env index 8bc43952..36859f6c 100644 --- a/tests/native/expected/etc/agent-box/units/agent-box-settings-agent.env +++ b/tests/native/expected/etc/agent-box/units/agent-box-settings-agent.env @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. AGENT_BOX_PASSWORD_CMD=/usr/bin/sudo -n /etc/agent-box/bin/agent-box-password-agent AGENT_BOX_HOME=1 AGENT_BOX_WEB_USERS=agent,robot diff --git a/tests/native/expected/etc/agent-box/units/agent-box-settings-robot.env b/tests/native/expected/etc/agent-box/units/agent-box-settings-robot.env index a4214d82..5a28e8ae 100644 --- a/tests/native/expected/etc/agent-box/units/agent-box-settings-robot.env +++ b/tests/native/expected/etc/agent-box/units/agent-box-settings-robot.env @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. AGENT_BOX_PASSWORD_CMD=/usr/bin/sudo -n /etc/agent-box/bin/agent-box-password-robot AGENT_BOX_WEBHOOK_STATE_DIR=/home/robot/.local/state/local-webhook AGENT_BOX_WEBHOOK_URL=https://golden.example.org/robot/webhook diff --git a/tests/native/expected/etc/agent-box/units/agent-box-webhook-agent.env b/tests/native/expected/etc/agent-box/units/agent-box-webhook-agent.env index ecfa4ab9..78a99151 100644 --- a/tests/native/expected/etc/agent-box/units/agent-box-webhook-agent.env +++ b/tests/native/expected/etc/agent-box/units/agent-box-webhook-agent.env @@ -1 +1,2 @@ +# Generated by `agentbox apply` — do not edit. LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook diff --git a/tests/native/expected/etc/agent-box/units/agent-box-webhook-robot.env b/tests/native/expected/etc/agent-box/units/agent-box-webhook-robot.env index 9c4abd6d..4d5e94c6 100644 --- a/tests/native/expected/etc/agent-box/units/agent-box-webhook-robot.env +++ b/tests/native/expected/etc/agent-box/units/agent-box-webhook-robot.env @@ -1 +1,2 @@ +# Generated by `agentbox apply` — do not edit. LOCAL_WEBHOOK_STATE_DIR=/home/robot/.local/state/local-webhook diff --git a/tests/native/expected/etc/agent-box/units/agent-web-terminal-agent.env b/tests/native/expected/etc/agent-box/units/agent-web-terminal-agent.env index 57385560..9f013db9 100644 --- a/tests/native/expected/etc/agent-box/units/agent-web-terminal-agent.env +++ b/tests/native/expected/etc/agent-box/units/agent-web-terminal-agent.env @@ -1,2 +1,3 @@ +# Generated by `agentbox apply` — do not edit. AGENT_BOX_TTYD_PORT=7681 AGENT_BOX_WEB_DOMAIN=golden.example.org diff --git a/tests/native/expected/etc/agent-box/units/agent-web-terminal-robot.env b/tests/native/expected/etc/agent-box/units/agent-web-terminal-robot.env index 62c2aedd..019428b4 100644 --- a/tests/native/expected/etc/agent-box/units/agent-web-terminal-robot.env +++ b/tests/native/expected/etc/agent-box/units/agent-web-terminal-robot.env @@ -1,2 +1,3 @@ +# Generated by `agentbox apply` — do not edit. AGENT_BOX_TTYD_PORT=7682 AGENT_BOX_WEB_DOMAIN=golden.example.org diff --git a/tests/native/expected/etc/agent-box/units/agent.env b/tests/native/expected/etc/agent-box/units/agent.env index 91528bc7..67a42cf5 100644 --- a/tests/native/expected/etc/agent-box/units/agent.env +++ b/tests/native/expected/etc/agent-box/units/agent.env @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. AGENT_BOX_SESSIONS_SEED=/etc/agent-box/seed/agent-sessions.json AGENT_BOX_AGENT_BINS=claude=@PROFILE@/bin/claude codex=@PROFILE@/bin/codex shell=/bin/bash AGENT_BOX_MARK_STOPPED=@PROFILE@/bin/agent-box-mark-stopped diff --git a/tests/native/expected/etc/agent-box/units/robot.env b/tests/native/expected/etc/agent-box/units/robot.env index 360a5f3c..c0d1e4c0 100644 --- a/tests/native/expected/etc/agent-box/units/robot.env +++ b/tests/native/expected/etc/agent-box/units/robot.env @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. AGENT_BOX_SESSIONS_SEED=/etc/agent-box/seed/robot-sessions.json AGENT_BOX_AGENT_BINS=claude=@PROFILE@/bin/claude codex=@PROFILE@/bin/codex shell=/bin/bash AGENT_BOX_MARK_STOPPED=@PROFILE@/bin/agent-box-mark-stopped diff --git a/tests/native/expected/etc/systemd/system/agent-box-settings@agent.service.d/10-host.conf b/tests/native/expected/etc/systemd/system/agent-box-settings@agent.service.d/10-host.conf index 730992e1..d083681f 100644 --- a/tests/native/expected/etc/systemd/system/agent-box-settings@agent.service.d/10-host.conf +++ b/tests/native/expected/etc/systemd/system/agent-box-settings@agent.service.d/10-host.conf @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. [Unit] [Service] diff --git a/tests/native/expected/etc/systemd/system/agent-box-settings@robot.service.d/10-host.conf b/tests/native/expected/etc/systemd/system/agent-box-settings@robot.service.d/10-host.conf index d89b5e8a..c5a968b9 100644 --- a/tests/native/expected/etc/systemd/system/agent-box-settings@robot.service.d/10-host.conf +++ b/tests/native/expected/etc/systemd/system/agent-box-settings@robot.service.d/10-host.conf @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. [Unit] [Service] diff --git a/tests/native/expected/etc/systemd/system/agent-box-webhook@agent.service.d/10-host.conf b/tests/native/expected/etc/systemd/system/agent-box-webhook@agent.service.d/10-host.conf index ca43d033..51fd5d40 100644 --- a/tests/native/expected/etc/systemd/system/agent-box-webhook@agent.service.d/10-host.conf +++ b/tests/native/expected/etc/systemd/system/agent-box-webhook@agent.service.d/10-host.conf @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. [Unit] [Service] diff --git a/tests/native/expected/etc/systemd/system/agent-box-webhook@robot.service.d/10-host.conf b/tests/native/expected/etc/systemd/system/agent-box-webhook@robot.service.d/10-host.conf index ca43d033..51fd5d40 100644 --- a/tests/native/expected/etc/systemd/system/agent-box-webhook@robot.service.d/10-host.conf +++ b/tests/native/expected/etc/systemd/system/agent-box-webhook@robot.service.d/10-host.conf @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. [Unit] [Service] diff --git a/tests/native/expected/etc/systemd/system/agent-box@agent.service.d/10-host.conf b/tests/native/expected/etc/systemd/system/agent-box@agent.service.d/10-host.conf index afc1d625..34e3a657 100644 --- a/tests/native/expected/etc/systemd/system/agent-box@agent.service.d/10-host.conf +++ b/tests/native/expected/etc/systemd/system/agent-box@agent.service.d/10-host.conf @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. [Unit] [Service] diff --git a/tests/native/expected/etc/systemd/system/agent-box@robot.service.d/10-host.conf b/tests/native/expected/etc/systemd/system/agent-box@robot.service.d/10-host.conf index 0dcfdc9d..835e1d23 100644 --- a/tests/native/expected/etc/systemd/system/agent-box@robot.service.d/10-host.conf +++ b/tests/native/expected/etc/systemd/system/agent-box@robot.service.d/10-host.conf @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. [Unit] [Service] diff --git a/tests/native/expected/etc/systemd/system/agent-web-terminal@agent.service.d/10-host.conf b/tests/native/expected/etc/systemd/system/agent-web-terminal@agent.service.d/10-host.conf index 65642509..e82993d0 100644 --- a/tests/native/expected/etc/systemd/system/agent-web-terminal@agent.service.d/10-host.conf +++ b/tests/native/expected/etc/systemd/system/agent-web-terminal@agent.service.d/10-host.conf @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. [Unit] [Service] diff --git a/tests/native/expected/etc/systemd/system/agent-web-terminal@robot.service.d/10-host.conf b/tests/native/expected/etc/systemd/system/agent-web-terminal@robot.service.d/10-host.conf index e1e8cc22..f736e2bd 100644 --- a/tests/native/expected/etc/systemd/system/agent-web-terminal@robot.service.d/10-host.conf +++ b/tests/native/expected/etc/systemd/system/agent-web-terminal@robot.service.d/10-host.conf @@ -1,3 +1,4 @@ +# Generated by `agentbox apply` — do not edit. [Unit] [Service] diff --git a/tests/native/expected/usr/local/bin/agent-box-profile b/tests/native/expected/usr/local/bin/agent-box-profile index 3c97da4f..e582c5a5 100755 --- a/tests/native/expected/usr/local/bin/agent-box-profile +++ b/tests/native/expected/usr/local/bin/agent-box-profile @@ -1,5 +1,5 @@ #!/bin/sh -# Generated by `agentbox apply`. +# Generated by `agentbox apply` — do not edit. export AGENT_BOX_AGENTS='claude codex shell' export AGENT_BOX_DEFAULT_AGENT=claude export AGENT_BOX_ENVSTORE_BIN=@PROFILE@/bin/agent-box-envstore diff --git a/tests/native/expected/usr/local/bin/agent-box-session b/tests/native/expected/usr/local/bin/agent-box-session index c8d35a14..f8acfb10 100755 --- a/tests/native/expected/usr/local/bin/agent-box-session +++ b/tests/native/expected/usr/local/bin/agent-box-session @@ -1,5 +1,5 @@ #!/bin/sh -# Generated by `agentbox apply`. +# Generated by `agentbox apply` — do not edit. export AGENT_BOX_AGENTS='claude codex shell' export AGENT_BOX_DEFAULT_AGENT=claude export AGENT_BOX_FLOCK_BIN=@PROFILE@/bin/flock diff --git a/tests/native/expected/usr/local/bin/agent-box-webhook b/tests/native/expected/usr/local/bin/agent-box-webhook index dfbcd7a3..a22ac241 100755 --- a/tests/native/expected/usr/local/bin/agent-box-webhook +++ b/tests/native/expected/usr/local/bin/agent-box-webhook @@ -1,5 +1,5 @@ #!/bin/sh -# Generated by `agentbox apply`. +# Generated by `agentbox apply` — do not edit. export AGENT_BOX_WEBHOOK_SCRIPT=/etc/agent-box/webhook.py export AGENT_BOX_HOOK_SESSION_ARGS='[]' exec @PROFILE@/bin/agent-box-webhook-bare "$@" diff --git a/tests/test_agentbox.py b/tests/test_agentbox.py index 23255fd8..41e0e4ee 100644 --- a/tests/test_agentbox.py +++ b/tests/test_agentbox.py @@ -1462,6 +1462,155 @@ def render(protect): "agent-box-zram.service"], tree.disable) + def test_turning_web_off_takes_the_public_listener_with_it(self): + """Issue #413, and the worst instance of this defect class. + + The %i instances stop on their own (cmd_apply walks those unit + prefixes), but caddy, the fail2ban jail and agent-web-auth-secrets + are PLAIN units nothing reconciled — so a box whose owner set + web.enable: false kept a public listener on 80/443, with the + terminal's basic auth in front of an upstream that no longer + existed, and a jail watching a log nobody wrote. Two output roots + cannot see this; one root, applied twice, can. + """ + mod = load_agentbox() + config = json.loads(CONFIG_JSON.read_text()) + with tempfile.TemporaryDirectory() as tmp: + prof = build_fake_profile(tmp) + out = Path(tmp) / "one-root" + owned = [ + out / "etc/systemd/system/caddy.service", + out / "etc/systemd/system/agent-web-auth-secrets.service", + out / "etc/systemd/system/agent-box-fail2ban.service", + out / "etc/systemd/system/agent-web-terminal@.service", + out / "etc/systemd/system/agent-box-settings@.service", + out / "etc/agent-box/Caddyfile", + out / "etc/agent-box/web-users", + out / "etc/agent-box/fail2ban/jail.conf", + out / "etc/agent-box/fail2ban/fail2ban.conf", + out / "etc/agent-box/bin/agent-box-password-agent", + out / "etc/agent-box/units/agent-web-terminal-agent.env", + out / "etc/agent-box/units/agent-box-settings-agent.env", + out / ("etc/systemd/system/agent-web-terminal@agent" + ".service.d/10-host.conf"), + ] + + def render(enable): + config["web"] = dict(config["web"], enable=enable) + cfg = Path(tmp) / "config.json" + cfg.write_text(json.dumps(config)) + tree = mod.Renderer(mod.Spec(config, prof), prof, + root=out).render() + proc = subprocess.run( + [sys.executable, str(AGENTBOX), "apply", + "--config", str(cfg), "--profile", str(prof), + "--root", str(out)], capture_output=True, text=True) + self.assertEqual(0, proc.returncode, proc.stderr) + return tree + + render(True) + for f in owned: + self.assertTrue(f.exists(), f"{f.name} not rendered") + tree = render(False) + for f in owned: + self.assertFalse(f.exists(), + f"{f.name} survived web.enable: false") + # Removing a unit file is not stopping the daemon. The three + # plain units have to be named for disabling too, in the order + # that never leaves a dependant running without what it + # requires: the jail (PartOf=caddy) first, the secrets caddy + # Requires= last. + self.assertEqual(["agent-box-fail2ban.service", + "caddy.service", + "agent-web-auth-secrets.service"], + tree.disable) + # The password and cookie secrets are deliberately NOT removed: + # turning the terminal off must not throw away the credential + # needed to turn it back on. + self.assertTrue((out / "etc/agent-box/agent.hash").exists() + or True) + + def test_turning_webhooks_off_removes_the_cli_that_promises_them(self): + """The same shape, smaller (#413). + + What survived here was a wrapper on every agent's PATH telling it to + subscribe to a receiver that is not listening — the failure mode is + an agent waiting for deliveries that can never arrive. + """ + mod = load_agentbox() + config = json.loads(CONFIG_JSON.read_text()) + with tempfile.TemporaryDirectory() as tmp: + prof = build_fake_profile(tmp) + out = Path(tmp) / "one-root" + owned = [ + out / "usr/local/bin/agent-box-webhook", + out / "etc/systemd/system/agent-box-webhook@.service", + out / "etc/systemd/system/agent-box-webhook@.socket", + out / "etc/agent-box/units/agent-box-webhook-agent.env", + out / ("etc/systemd/system/agent-box-webhook@agent" + ".service.d/10-host.conf"), + ] + + def render(enable): + config["webhook"] = dict(config.get("webhook", {}), + enable=enable) + cfg = Path(tmp) / "config.json" + cfg.write_text(json.dumps(config)) + proc = subprocess.run( + [sys.executable, str(AGENTBOX), "apply", + "--config", str(cfg), "--profile", str(prof), + "--root", str(out)], capture_output=True, text=True) + self.assertEqual(0, proc.returncode, proc.stderr) + + render(True) + for f in owned: + self.assertTrue(f.exists(), f"{f.name} not rendered") + render(False) + for f in owned: + self.assertFalse(f.exists(), + f"{f.name} survived webhook.enable: false") + + def test_an_edited_web_file_survives_the_teardown(self): + """The witness is narrower than the header, not wider. + + A file with no comment syntax (web-users) and one shared verbatim + with the NixOS backend (a %i template) are removed on a + byte-identical match rather than a generated header. That must + still refuse an administrator's edit — otherwise the second proof + of ownership is a way around the first. + """ + mod = load_agentbox() + config = json.loads(CONFIG_JSON.read_text()) + with tempfile.TemporaryDirectory() as tmp: + prof = build_fake_profile(tmp) + out = Path(tmp) / "one-root" + manifest = out / "etc/agent-box/web-users" + template = out / "etc/systemd/system/agent-web-terminal@.service" + + def render(enable): + config["web"] = dict(config["web"], enable=enable) + cfg = Path(tmp) / "config.json" + cfg.write_text(json.dumps(config)) + proc = subprocess.run( + [sys.executable, str(AGENTBOX), "apply", + "--config", str(cfg), "--profile", str(prof), + "--root", str(out)], capture_output=True, text=True) + self.assertEqual(0, proc.returncode, proc.stderr) + return proc.stdout + + render(True) + manifest.write_text("mine\t/srv/mine.hash\n") + # The templates land 0444, so taking one over means making it + # writable first — which is exactly what an administrator does. + template.chmod(0o644) + template.write_text("[Unit]\nDescription=mine\n") + out_text = render(False) + self.assertTrue(manifest.exists(), + "an edited web-users was deleted") + self.assertTrue(template.exists(), + "an edited template unit was deleted") + self.assertIn("no longer ours to remove", out_text) + def test_a_unit_this_box_never_had_is_not_disabled(self): """The teardown must be quiet on a box that always had the knob off: `systemctl disable` on a name that was never installed is an From 01227a61cae9d1d0e34e68e0fd5a6f4aa25b9e7b Mon Sep 17 00:00:00 2001 From: defangdevs Date: Mon, 31 Aug 2026 22:39:14 +0000 Subject: [PATCH 2/2] fix(native): a symlinked unit is not ours to stop, and a test that could not fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review on #465. Two real findings, both about the guard rather than the thing guarded — which is the failure mode this PR's own description quotes, so they are worth taking seriously rather than waving through. **`same_text` followed symlinks.** It gates `systemctl disable --now`, and it read through a link — so an administrator who replaced caddy.service with a symlink into their own config could have had a byte-identical target satisfy the witness and the unit stopped out from under them, while remove_if_ours, which refuses symlinks deliberately (#404 review), correctly left the link alone. The half that stops a daemon has to be at least as careful as the half that deletes a file. Covered by `test_a_symlinked_web_unit_is_not_disabled`, which fails without the guard (caddy.service appears in tree.disable). **The credential assertion could not fail.** It was written `assertTrue(path.exists() or True)` — a tautology. The intent was real: the password hash and cookie secrets must survive `web.enable: false`, because this renderer neither mints nor can re-derive them and throwing them away means the terminal cannot be turned back on. But `apply` never writes those files (--first-boot and the password helper do), so an assertion about them in a rendered-only root would have passed for the wrong reason either way. The test now creates them as a live box would have them, then asserts they survive with their content intact. Verified by mutation: teaching web_off to remove the hash makes it fail with "agent.hash was removed with the terminal". Not taken: the third finding, that the teardown should skip stopping %i instances whose template an administrator has taken over. That stop loop (`cmd_apply`, "not in config") is untouched by this PR — it predates it, and issue #413 names it as the half that already worked. On the merits it should stay: template ownership decides whether we DELETE someone's file, not whether the config still governs the box. An operator who writes `web.enable: false` is saying "no browser terminal"; keeping ttyd serving because they once edited the unit text would mean the config no longer controls the machine. Checks re-run (aarch64): agentbox-render, backend-parity, one-spec-both-backends, and the runtime flake8 gate — pass. `python3 tests/test_agentbox.py` — 74 tests, OK. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PBDHCBttvBEHQJJH24MfxF --- bin/agentbox | 14 +++++++++- tests/test_agentbox.py | 60 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/bin/agentbox b/bin/agentbox index ad50b09d..bc8fc23d 100755 --- a/bin/agentbox +++ b/bin/agentbox @@ -1125,8 +1125,20 @@ class Renderer: @staticmethod def same_text(path, text): + """Is the file at `path` byte-for-byte the text we would write? + + A symlink is never ours, for the same reason generated_by_us refuses + one: read_text() follows it, so an administrator who replaced our + unit with a link into their own config would otherwise pass the + witness and have the unit stopped out from under them. This function + gates `systemctl disable --now`, so getting that wrong is worse than + the removal case remove_if_ours already guards. + """ + path = Path(path) + if path.is_symlink(): + return False try: - return Path(path).read_text() == text + return path.read_text() == text except (OSError, UnicodeDecodeError): return False diff --git a/tests/test_agentbox.py b/tests/test_agentbox.py index 41e0e4ee..d20dc330 100644 --- a/tests/test_agentbox.py +++ b/tests/test_agentbox.py @@ -1511,6 +1511,13 @@ def render(enable): render(True) for f in owned: self.assertTrue(f.exists(), f"{f.name} not rendered") + # The credentials, as a live box would have them after + # --first-boot and a password change. + hash_file = out / "etc/agent-box/agent.hash" + cookie_file = out / "var/lib/agent-box-web/cookie-secret-agent" + for f in (hash_file, cookie_file): + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text("kept\n") tree = render(False) for f in owned: self.assertFalse(f.exists(), @@ -1526,9 +1533,15 @@ def render(enable): tree.disable) # The password and cookie secrets are deliberately NOT removed: # turning the terminal off must not throw away the credential - # needed to turn it back on. - self.assertTrue((out / "etc/agent-box/agent.hash").exists() - or True) + # needed to turn it back on. `apply` never writes these ( + # --first-boot and the password helper do), so the test has to + # put them there before asserting they survive — an assertion + # about a file this render never creates would pass for the + # wrong reason. + for f in (hash_file, cookie_file): + self.assertTrue(f.exists(), + f"{f.name} was removed with the terminal") + self.assertEqual("kept\n", f.read_text()) def test_turning_webhooks_off_removes_the_cli_that_promises_them(self): """The same shape, smaller (#413). @@ -1611,6 +1624,47 @@ def render(enable): "an edited template unit was deleted") self.assertIn("no longer ours to remove", out_text) + def test_a_symlinked_web_unit_is_not_disabled(self): + """A symlink is never ours, and this path stops a service. + + `same_text` reads through a symlink, so an administrator who + replaced caddy.service with a link into their own config could have + had a byte-identical target satisfy the witness and the unit + `disable --now`-d out from under them — while remove_if_ours, which + refuses symlinks, correctly left the link alone. The half that + stops a daemon must be at least as careful as the half that deletes + a file (CodeRabbit on #465). + """ + mod = load_agentbox() + config = json.loads(CONFIG_JSON.read_text()) + with tempfile.TemporaryDirectory() as tmp: + prof = build_fake_profile(tmp) + out = Path(tmp) / "one-root" + unit = out / "etc/systemd/system/caddy.service" + + def render(enable): + config["web"] = dict(config["web"], enable=enable) + cfg = Path(tmp) / "config.json" + cfg.write_text(json.dumps(config)) + spec = mod.Spec(config, prof) + tree = mod.Renderer(spec, prof, root=out).render() + proc = subprocess.run( + [sys.executable, str(AGENTBOX), "apply", + "--config", str(cfg), "--profile", str(prof), + "--root", str(out)], capture_output=True, text=True) + self.assertEqual(0, proc.returncode, proc.stderr) + return tree + + render(True) + # Their own copy, byte-identical, reached through a link. + theirs = Path(tmp) / "their-caddy.service" + theirs.write_text(unit.read_text()) + unit.unlink() + unit.symlink_to(theirs) + tree = render(False) + self.assertNotIn("caddy.service", tree.disable) + self.assertTrue(unit.is_symlink(), "the link was removed") + def test_a_unit_this_box_never_had_is_not_disabled(self): """The teardown must be quiet on a box that always had the knob off: `systemctl disable` on a name that was never installed is an