Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 215 additions & 19 deletions bin/agentbox

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions tests/native/expected/etc/agent-box/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
# Generated by `agentbox apply` — do not edit.
LOCAL_WEBHOOK_STATE_DIR=/home/agent/.local/state/local-webhook
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
# Generated by `agentbox apply` — do not edit.
LOCAL_WEBHOOK_STATE_DIR=/home/robot/.local/state/local-webhook
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# Generated by `agentbox apply` — do not edit.
AGENT_BOX_TTYD_PORT=7681
AGENT_BOX_WEB_DOMAIN=golden.example.org
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# Generated by `agentbox apply` — do not edit.
AGENT_BOX_TTYD_PORT=7682
AGENT_BOX_WEB_DOMAIN=golden.example.org
1 change: 1 addition & 0 deletions tests/native/expected/etc/agent-box/units/agent.env
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/native/expected/etc/agent-box/units/robot.env
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Generated by `agentbox apply` — do not edit.
[Unit]

[Service]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Generated by `agentbox apply` — do not edit.
[Unit]

[Service]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Generated by `agentbox apply` — do not edit.
[Unit]

[Service]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Generated by `agentbox apply` — do not edit.
[Unit]

[Service]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Generated by `agentbox apply` — do not edit.
[Unit]

[Service]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Generated by `agentbox apply` — do not edit.
[Unit]

[Service]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Generated by `agentbox apply` — do not edit.
[Unit]

[Service]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Generated by `agentbox apply` — do not edit.
[Unit]

[Service]
Expand Down
2 changes: 1 addition & 1 deletion tests/native/expected/usr/local/bin/agent-box-profile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/native/expected/usr/local/bin/agent-box-session
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/native/expected/usr/local/bin/agent-box-webhook
Original file line number Diff line number Diff line change
@@ -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 "$@"
203 changes: 203 additions & 0 deletions tests/test_agentbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -1462,6 +1462,209 @@ 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")
# 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(),
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. `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).

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_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
Expand Down