Skip to content

refactor(native): Ubuntu/RHEL backend — no nixos-infect (#154 Phase 4) - #325

Draft
defangdevs wants to merge 3 commits into
refactor/phase3-systemd-template-unitsfrom
refactor/phase4-native-backend
Draft

refactor(native): Ubuntu/RHEL backend — no nixos-infect (#154 Phase 4)#325
defangdevs wants to merge 3 commits into
refactor/phase3-systemd-template-unitsfrom
refactor/phase4-native-backend

Conversation

@defangdevs

@defangdevs defangdevs commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Phase 4 of #154: agent-box on a stock Ubuntu/RHEL host, so a Lightsail launch needs no nixos-infect, no reboot, no root-fs relabel, and 1 GiB bundles become viable. Draft — stacked on #295 (Phase 3), which must land first; review that one before this.

Context for the timing: the infect-based 1-click has never actually booted. Lightsail prepends its own #!/bin/sh preamble to the launch script, so the bootstrap ran under dash and died on set -o pipefail 19s into first boot — diagnosed on live hardware, written up in #324. We are not fixing that path; this replaces it.

Landed

1. nix/runtime.nix + packages.runtime — where software comes from with no system closure:

nix profile install github:defangdevs/agent-box/<rev>#runtime

Payloads are built from modules/src/* with builtins.readFile — the same assets the module embeds, with no Nix-string escaping in between. Phase 2 is what makes it work: payloads read AGENT_BOX_* env and resolve binaries from PATH, so neither backend owns them. Layout: bin/ (payloads, tools, agent CLIs, agentbox, agent-box-python), bin/agent-box-*-bare (interactive CLIs minus the env prelude), share/agent-box/{units,caddy,guides,web}, libexec/agent-box/password-helper.py.

2. bin/agentbox — the native config layer. agentbox apply reads /etc/agent-box/config.yaml (or .json) and idempotently renders the per-user env files, the per-instance host drop-ins, the %i units byte-for-byte out of the profile, sudoers.d, tmpfiles.d, the session seed, the Caddyfile (same @TOKEN@ fragments), a native caddy.service, the per-user password helper, and the interactive CLI wrappers; then useradd/systemctl enable what the config declares and disable instances for users removed from it. apply --first-boot absorbs the CFN inline logic (settle the public IPv4, derive the sslip.io hostname, hash the password with argon2id, write caddy's env file); agentbox signal PUTs the WaitCondition result, separate so a template signals only once the box serves.

3. aws/lightsail-native-template.yaml — install Nix, install the profile, write the config, apply, signal. Timeout 2700 s → 1200 s; nano_3_0 is the new default bundle; the console's browser SSH keeps working because nothing lustrates /home.

Two corrections to the design in this issue

  1. The payload cannot be a #cloud-config. On Lightsail the launch script is concatenated into a shell script, so a YAML document would never be parsed as one. It stays a shell script here; a native EC2 template can use #cloud-config.
  2. ExecSearchPath with a bare ExecStart is not used — the native drop-ins use absolute paths per instance, matching what refactor(module): systemd template units, %i = user (#154 Phase 3) #295 found in practice.

Tests

  • agentbox-render (new check): tests/test_agentbox.py renders tests/native/config.{json,yaml} into a tree and diffs it against tests/native/expected; asserts units are installed verbatim, a re-apply rewrites nothing, JSON and YAML agree, bad configs are rejected. Then it hands the rendered Caddyfile to caddy validate with a real argon2id hash — the thing a byte fixture cannot tell you. It caught two real bugs already: four unbound Caddyfile tokens (@WEBHOOK_PATH@, @WEBHOOK_SOCKET@, @DOWNLOADS_DIR@, @TTYD_PORT@ — the renderer now fails loudly on any unbound token), and the password helper's constants appended in the wrong order.
  • runtime-profile (new check): every shipped payload must be its modules/src file verbatim, assets byte-identical, scripts bash -n-clean, PATH tools present.
  • scripts/check_lightsail_userdata.py (salvaged from fix(lightsail): re-exec the launch script under bash — the 1-click has never booted #324): fails on a missing bash re-exec guard, a bashism ahead of it, or a prefix that will not parse under dash -n.
  • Hermetic: the render tests need no Nix and no network (fake profile built from modules/src). File modes live in expected-modes.json, because the fixture reaches CI through the Nix store, which normalizes every file to 0444.

Green locally on aarch64: runtime-profile, agentbox-render, golden-snapshot, module-generated-up-to-date, plus aws cloudformation validate-template on the new template.

Still to come

  • Live acceptance test — no native launch has run on hardware yet. This is the gap that let the infect template ship broken for 10 days, so it should gate any docs/index.html repoint.
  • A Lightsail leg in deploy-test.yml (needs lightsail:* on the test account's OIDC role, which this repo does not manage).
  • Distro specifics: SELinux policy module + dnf-automatic (RHEL), unattended-upgrades check (Ubuntu).
  • agent-web-auth-secrets equivalent for password rotation is handled by the shared helper; the singleton's remaining manifest rework (design item 5) is still open.
  • Phase 5: evaluate the NixOS option tree into the same spec config.yaml uses, and diff both renders in one check.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN

First piece of Phase 4: where the software comes from when there is no
system closure. `nix profile install github:defangdevs/agent-box/<rev>#runtime`
gets a distro host the same payloads, tools, agent CLIs and shared assets
the NixOS backend puts in its closure — atomically switchable and
`nix profile rollback`-able, with the distro package manager left to base-OS
patching only.

nix/runtime.nix builds every payload from modules/src/*, the same assets
bin/assemble-module.py embeds into the module, via builtins.readFile — so no
Nix-string escaping stands between the two backends and a payload cannot
drift by way of a quoting difference. Phase 2 is what makes this possible:
the payloads take their configuration from AGENT_BOX_* env and resolve
binaries from PATH, so neither backend owns them.

Layout the native host layer consumes:
  bin/                       unit-driven payloads + tools + agent CLIs
  bin/agent-box-*-bare       interactive CLIs, minus the env prelude that
                             `agentbox apply` will generate per host
  share/agent-box/units/     the %i template units, verbatim
  share/agent-box/caddy/     Caddyfile fragments (@token@ templates)
  share/agent-box/guides/    default AGENTS.md sources
  share/agent-box/web/       settings page assets
  libexec/agent-box/         password-helper.py — generated per user, since
                             it crosses sudo and must not take paths from env

New check `runtime-profile` asserts each shipped payload is its modules/src
file verbatim, that the shared assets are byte-identical, that every script
parses under bash -n, and that the tools a session's PATH needs are present.
That is the guard against the failure that would matter most here: a native
box quietly running a different supervisor than the one tests/golden locks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN
lionello and others added 2 commits August 21, 2026 22:42
Second piece of Phase 4: what renders the host state when there is no
option tree and no nixos-rebuild. `agentbox apply` reads
/etc/agent-box/config.yaml (or .json — cloud-init can write either) and
idempotently renders exactly what the module renders on NixOS:

  - /etc/agent-box/units/<user>.env and the web/settings/webhook peers —
    the AGENT_BOX_* contract Phase 2 established
  - per-instance host drop-ins (absolute ExecStart, PATH, tool pins), the
    shape #295 landed on after finding that a template-level drop-in never
    merges into an instance
  - the %i template units, installed byte-for-byte out of the profile
  - sudoers.d, tmpfiles.d, the session seed, the AGENTS.md pointer
  - the Caddyfile, bound from the same @token@ fragments, plus a native
    caddy.service (the shared password helper reloads that unit by name)
  - the per-user password helper, with its constants APPENDED exactly as
    the module's generated tail does — including the one override the
    native side needs, since the body's SYSTEMCTL default is a NixOS path
  - the interactive CLI wrappers, which cannot take their env from a unit
    because they run from every PATH there is

`apply --first-boot` absorbs what the CFN UserData did inline: settle the
public IPv4, derive the sslip.io hostname, hash the web password with
argon2id (the same algorithm and stdin invocation the password helper uses
for a later change), and write the caddy env file. `agentbox signal` PUTs
the WaitCondition result, kept separate so a template signals only once the
box actually serves.

Testing, because a native box has no golden fixture to fall back on:
tests/test_agentbox.py renders into a tree (`apply --root`) and diffs it
against tests/native/expected — hermetic, from a fake profile built out of
modules/src, so it runs under a plain python3 with no Nix and no network.
It also asserts the units are installed verbatim, that a re-apply wants to
rewrite nothing, that JSON and YAML configs render identically, and that
bad configs are rejected. File modes are recorded in expected-modes.json
rather than as the fixture's own permissions: the fixture reaches CI
through the Nix store, which normalizes every file to 0444.

New check `agentbox-render` runs that suite and then hands the rendered
Caddyfile to `caddy validate` with a real argon2id hash — the one thing a
byte fixture cannot tell you, that the config actually adapts and
provisions. It found two real bugs already: @WEBHOOK_PATH@,
@WEBHOOK_SOCKET@, @DOWNLOADS_DIR@ and @TTYD_PORT@ were unbound (the
renderer now fails loudly on any unbound token), and the password helper
took its constants in the wrong order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN
Third piece of Phase 4: the 1-click that boots Ubuntu and stays Ubuntu.
aws/lightsail-native-template.yaml installs Nix as a package manager,
installs the pinned runtime profile, writes /etc/agent-box/config.yaml and
runs `agentbox apply --first-boot`, then signals the WaitCondition once the
box actually serves. No infection, no relabel, no reboot: the timeout drops
from 2700 s to 1200 s, nano_3_0 (1 GiB) becomes a usable bundle, and the
console's browser SSH keeps working because nothing lustrates /home.

Two corrections to the Phase 4 design, both from the live launch that
diagnosed the infect template's failure:

1. The payload CANNOT be the "#cloud-config" the design sketched. Lightsail
   prepends its own #!/bin/sh preamble to the launch script, so the payload
   is concatenated INTO a shell script — a YAML document would never be
   parsed as one. It stays a shell script here; a native EC2 template can
   use #cloud-config, since nothing is prepended there.
2. For the same reason the script re-execs itself under bash on its first
   line. This is what the infect template needed and never had.

scripts/check_lightsail_userdata.py (salvaged from the closed #324) enforces
that guard: it fails if the guard is missing, if a bashism appears before
it, or if the prefix does not parse under dash -n. aws-ci.yml now cfn-lints
the new template, runs that check, and runs the native renderer's tests.

Not covered: the end-to-end launch has never run on live hardware. The
README records that, and where to read the bootstrap log when a stack times
out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants