Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
32b2a94
refactor(module): systemd template units, %i = user (#154 Phase 3)
lionello Aug 19, 2026
99efbec
fix(module): enable per-user template instances via target Wants, not…
lionello Aug 19, 2026
5bfe9f7
test(golden): capture multi-user.target/sockets.target in the snapshot
lionello Aug 19, 2026
2bd8149
fix(assemble): @@include marker regex can't handle an @ in the target…
lionello Aug 19, 2026
97b40ee
fix(module): give the per-instance agent-box@<user> drop-in the real …
lionello Aug 19, 2026
22ba2ed
fix(module): override ExecStart/ExecStop with absolute paths, not sea…
lionello Aug 19, 2026
a91d43e
test(memory-protection): TEMP diagnostic - print the real merged unit…
lionello Aug 19, 2026
23be9e3
test(memory-protection): TEMP diagnostic - check filesystem + journal…
lionello Aug 19, 2026
1c8c600
test(memory-protection): TEMP diagnostic - test the stale-loaded-unit…
lionello Aug 19, 2026
0f1554e
fix(module): move all host-level unit content off template-level drop…
lionello Aug 19, 2026
64ff743
test: fix two pre-existing test bugs that CI reached for the first time
lionello Aug 19, 2026
6efd3c8
test: fix two more pre-existing test/architecture mismatches
lionello Aug 19, 2026
5a97e27
fix(module): give agent-box-attach an absolute path in ttyd's ExecSta…
lionello Aug 19, 2026
7945b95
Merge branch 'master' into refactor/phase3-systemd-template-units
lionello Aug 25, 2026
cba942e
fix(module): two integration breaks the merge surfaced in CI
lionello Aug 25, 2026
734c4bc
test(connect): TEMP diagnostic - show what the sign-in pane actually did
lionello Aug 25, 2026
f7f37b3
fix(test): connect.nix's stub override targets a phantom unit name
defangdevs Aug 25, 2026
68c8bb9
test(connect): drop the TEMP diagnostic now the cause is named
lionello Aug 25, 2026
ea950b8
test(sessions-web): require a whole-token agent-box-attach path
lionello Aug 25, 2026
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
2 changes: 1 addition & 1 deletion bin/assemble-module.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from pathlib import Path

MARKER = re.compile(
r"^(?P<indent>[ ]*)@@include(?P<verbatim>-verbatim)?:(?P<path>[^@]+)@@[ ]*$"
r"^(?P<indent>[ ]*)@@include(?P<verbatim>-verbatim)?:(?P<path>.+)@@[ ]*$"
)

BANNER = """\
Expand Down
28 changes: 25 additions & 3 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,30 @@
# whole Caddy/ttyd/settings/webhook/self-update surface too.
web = [ self.nixosModules.agent-box ./hosts/vm.nix ./tests/golden-web.nix ];
};
# multi-user.target/sockets.target (issue #154 Phase 3): the module
# doesn't own these units, but it drops a `Wants=` override onto
# each one to enable a per-user %i template instance (see the
# agent-box@/agent-box-settings@/agent-box-webhook@/
# agent-web-terminal@ instances below) — a mechanism the earlier
# per-instance `wantedBy` attempt got wrong in a way no eval-level
# check caught (only a real VM boot did, unit stayed inactive).
# Capturing the override text here is what would have caught it.
unitFilter = n:
builtins.match "(agent-box|agent-web|caddy|fail2ban|earlyoom).*" n != null;
builtins.match
"(agent-box|agent-web|caddy|fail2ban|earlyoom).*|multi-user\\.target|sockets\\.target"
n != null;
# /etc content the module owns or materially shapes. The fail2ban
# dir entries (filter.d/, action.d/) are upstream package trees and
# deliberately excluded; the module's own filter and the jail
# settings land in the files below.
etcFilter = n:
builtins.match
"agent-box-guides/.*|caddy/caddy_config|codex/config\\.toml|fail2ban/(fail2ban|jail)\\.local|fail2ban/filter\\.d/agent-web-auth\\.conf|sudoers"
# agent-box/units/*.env (issue #154 Phase 3): the per-user
# generated env files the "%i" template units' EnvironmentFile=
# reads — the payload capture below only scans Nix-visible
# `environment` attrs, so these plain-text files are the review
# surface for what moved out of that attrset.
"agent-box-guides/.*|agent-box/units/.*|caddy/caddy_config|codex/config\\.toml|fail2ban/(fail2ban|jail)\\.local|fail2ban/filter\\.d/agent-web-auth\\.conf|sudoers"
n != null;
manifestOf = modules:
let sys = nixpkgs.lib.nixosSystem { inherit system modules; }; in
Expand Down Expand Up @@ -171,7 +186,14 @@
];
};
services = multiUser.config.systemd.services;
wanted = [ "agent-box-alice" "agent-box-bob" "agent-box-coder" "agent-box-ci" ];
# issue #154 Phase 3: "agent-box@" is the systemd %i template unit,
# shipped verbatim via systemd.packages (not a systemd.services
# Nix declaration — a template-level drop-in there was found to
# silently never merge into any real instance, so all host-level
# content moved onto the per-instance declaration below). Each
# configured user gets its own "agent-box@<user>" drop-in instead
# of a flat "agent-box-<user>" unit.
wanted = [ "agent-box@alice" "agent-box@bob" "agent-box@coder" "agent-box@ci" ];
missing = builtins.filter (n: ! builtins.hasAttr n services) wanted;
in
{
Expand Down
972 changes: 550 additions & 422 deletions modules/agent-box.nix

Large diffs are not rendered by default.

845 changes: 424 additions & 421 deletions modules/agent-box.nix.in

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion modules/src/spot-monitor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ while true; do
sleep "$GRACE"
echo "spot-monitor: stopping agent units (no respawn)" >&2
for u in $USERS; do
$SYSTEMCTL stop "agent-box-$u.service" || true
$SYSTEMCTL stop "agent-box@$u.service" || true
done
$SYNC
echo "spot-monitor: prepared; leaving the stop to AWS (preserves persistent-Spot auto-restart)" >&2
Expand Down
31 changes: 31 additions & 0 deletions modules/src/units/agent-box-settings@.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[Unit]
Description=Per-user secrets settings page for %i
After=network-online.target agent-box-settings@%i.socket
Requires=agent-box-settings@%i.socket
Wants=network-online.target

[Service]
User=%i
Restart=always
RestartSec=5s
Environment=TMUX_TMPDIR=/run/agent-box-%i
Environment=AGENT_BOX_SETTINGS_USER=%i
Environment=AGENT_BOX_SETTINGS_ENV_FILE=/home/%i/.config/agent-box/env
Environment=AGENT_BOX_SETTINGS_BASE=/%i/settings
Environment=AGENT_BOX_TMUX_SOCKET=agent-box
Environment=AGENT_BOX_TMUX_TMPDIR=/run/agent-box-%i
Environment=AGENT_BOX_SESSIONS_FILE=/home/%i/.config/agent-box/sessions.json
EnvironmentFile=-/etc/agent-box/units/agent-box-settings-%i.env
EnvironmentFile=-/etc/agent-box/units/agent-box-settings-%i.local.env
ExecStart=agent-box-settings
ProtectSystem=strict
ReadWritePaths=/home/%i /run/agent-box-%i /var/lib/agent-box-web
ProtectHome=false
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
NoNewPrivileges=false
8 changes: 8 additions & 0 deletions modules/src/units/agent-box-settings@.socket
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[Unit]
Description=Settings page socket for %i

[Socket]
ListenStream=/run/agent-box-settings/%i.sock
SocketUser=%i
SocketGroup=caddy
SocketMode=0660
40 changes: 40 additions & 0 deletions modules/src/units/agent-box-webhook@.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[Unit]
Description=Webhook receiver daemon (local-webhook) for %i
After=network-online.target agent-box-webhook@%i.socket
Requires=agent-box-webhook@%i.socket
Wants=network-online.target

[Service]
User=%i
Restart=always
RestartSec=5s
Environment=LOCAL_WEBHOOK_RECEIVER_ONLY=1
Environment=LOCAL_WEBHOOK_STATE_DIR=/home/%i/.local/state/local-webhook
Environment=LOCAL_WEBHOOK_PORT=0
EnvironmentFile=-/etc/agent-box/units/agent-box-webhook-%i.env
EnvironmentFile=-/etc/agent-box/units/agent-box-webhook-%i.local.env
# LOCAL_WEBHOOK_SELF: who this box acts as, so "@self" in a standing
# watch's ignoreSenders resolves (issue #261). Written by
# agent-box-webhook-self from the token in a SESSION's environment - the
# only place that token exists - which is why this is a file the receiver
# reads and not a value the module declares: the identity follows the
# token, and the token arrives (and is swapped) at runtime. Optional
# ("-"): absent before the first session resolves it, and on a box whose
# user never gives the agent a token. Loaded at start, so a re-resolved
# login reaches the daemon on its next restart; sessions pick it up as
# they respawn.
EnvironmentFile=-/home/%i/.local/state/local-webhook/self.env
ExecStartPre=agent-box-webhook-policy-apply
ExecStart=agent-box-webhook-receiver
StandardInput=null
ProtectSystem=strict
ReadWritePaths=/home/%i
ProtectHome=false
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
NoNewPrivileges=true
8 changes: 8 additions & 0 deletions modules/src/units/agent-box-webhook@.socket
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[Unit]
Description=Webhook ingress socket for %i

[Socket]
ListenStream=/run/agent-box-webhook/%i.sock
SocketUser=%i
SocketGroup=caddy
SocketMode=0660
30 changes: 30 additions & 0 deletions modules/src/units/agent-box@.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[Unit]
Description=Coding agent sessions (tmux) for %i
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
User=%i
Restart=always
RestartSec=2s
ExecStart=agent-box-supervisor
ExecStop=tmux -L agent-box kill-server
RuntimeDirectory=agent-box-%i
RuntimeDirectoryMode=0700
RuntimeDirectoryPreserve=yes
Environment=HOME=/home/%i
Environment=TMUX_TMPDIR=/run/agent-box-%i
EnvironmentFile=-/etc/agent-box/units/%i.env
EnvironmentFile=-/etc/agent-box/units/%i.local.env
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ReadWritePaths=/home/%i
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
14 changes: 14 additions & 0 deletions modules/src/units/agent-web-terminal@.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[Unit]
Description=Browser terminal (ttyd) attached to %i's tmux
After=agent-box@%i.service network-online.target
Wants=network-online.target

[Service]
User=%i
Restart=always
RestartSec=5s
Environment=TMUX_TMPDIR=/run/agent-box-%i
Environment=AGENT_BOX_SESSIONS_FILE=/home/%i/.config/agent-box/sessions.json
EnvironmentFile=-/etc/agent-box/units/agent-web-terminal-%i.env
EnvironmentFile=-/etc/agent-box/units/agent-web-terminal-%i.local.env
ExecStart=ttyd --writable --url-arg -p ${AGENT_BOX_TTYD_PORT} -i 127.0.0.1 -b /%i -t disableLeaveAlert=true -t titleFixed=%i@${AGENT_BOX_WEB_DOMAIN} -t macOptionClickForcesSelection=true agent-box-attach
8 changes: 4 additions & 4 deletions tests/connect.nix
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ in
# variable the module computes, so the daemon cannot tell the
# difference — which is the point of naming binaries rather than
# relying on PATH.
systemd.services.agent-box-settings-agent.environment.AGENT_BOX_CONNECT_BINS =
systemd.services."agent-box-settings@agent".environment.AGENT_BOX_CONNECT_BINS =
lib.mkForce "claude=${stubClaude}/bin/claude github=${stubGh}/bin/gh";

system.activationScripts.agent-web-password-hash.text = ''
Expand Down Expand Up @@ -169,8 +169,8 @@ in
import shlex

start_all()
machine.wait_for_unit("agent-box-agent.service")
machine.wait_for_unit("agent-box-settings-agent.service")
machine.wait_for_unit("agent-box@agent.service")
machine.wait_for_unit("agent-box-settings@agent.service")

sock = "curl -s --max-time 20 --unix-socket /run/agent-box-settings/agent.sock"
page = "http://localhost/agent/settings/"
Expand Down Expand Up @@ -339,7 +339,7 @@ in
# A server started here would parent every session the supervisor
# later spawns, moving the agents out of their hardened unit's
# namespace and into the settings daemon's.
machine.succeed("systemctl stop agent-box-agent.service")
machine.succeed("systemctl stop agent-box@agent.service")
machine.wait_until_fails(tmux("list-sessions"))
assert post("/agent/settings/connect/start", "flow=claude") == "409"
machine.fail(tmux("list-sessions"))
Expand Down
6 changes: 5 additions & 1 deletion tests/golden/DUPLICATES
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@ web/payloads/agent-box-envstore/bin/agent-box-envstore -> vm/payloads/agent-box-
web/payloads/agent-box-profile/bin/agent-box-profile -> vm/payloads/agent-box-profile/bin/agent-box-profile
web/payloads/agent-box-session/bin/agent-box-session -> vm/payloads/agent-box-session/bin/agent-box-session
web/payloads/agent-box-spot-monitor -> vm/payloads/agent-box-spot-monitor
web/payloads/agent-box-supervisor -> vm/payloads/agent-box-supervisor
web/payloads/agent-box-supervisor/bin/agent-box-supervisor -> vm/payloads/agent-box-supervisor/bin/agent-box-supervisor
web/units/agent-box-settings@robot.service -> web/units/agent-box-settings@agent.service
web/units/agent-box-webhook@robot.service -> web/units/agent-box-webhook@agent.service
web/units/agent-box@robot.service -> web/units/agent-box@agent.service
web/units/agent-web-terminal@robot.service -> web/units/agent-web-terminal@agent.service
web/units/earlyoom.service -> vm/units/earlyoom.service
5 changes: 5 additions & 0 deletions tests/golden/vm/etc/agent-box/units/agent.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
AGENT_BOX_SESSIONS_SEED=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-agent-box-agent-sessions.json
AGENT_BOX_AGENT_BINS=claude=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-claude-code-2.1.206/bin/claude codex=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-codex-0.144.1/bin/codex shell=/run/current-system/sw/bin/bash
AGENT_BOX_MARK_STOPPED=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-agent-box-agent-mark-stopped
AGENT_BOX_AGENTS_POINTER=/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-agent-box-agent-agents-pointer.md
AGENT_BOX_GUIDE_TARGET=/etc/agent-box-guides/AGENTS.agent.md
2 changes: 1 addition & 1 deletion tests/golden/vm/payloads/agent-box-spot-monitor
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ while true; do
sleep "$GRACE"
echo "spot-monitor: stopping agent units (no respawn)" >&2
for u in $USERS; do
$SYSTEMCTL stop "agent-box-$u.service" || true
$SYSTEMCTL stop "agent-box@$u.service" || true
done
$SYNC
echo "spot-monitor: prepared; leaving the stop to AWS (preserves persistent-Spot auto-restart)" >&2
Expand Down
52 changes: 0 additions & 52 deletions tests/golden/vm/units/agent-box-agent.service

This file was deleted.

Loading