Skip to content

refactor(module): purge Nix antiquotes from payload bodies (#154 Phase 2) - #182

Merged
defangdevs merged 5 commits into
masterfrom
refactor/phase2-purge-antiquotes
Aug 12, 2026
Merged

refactor(module): purge Nix antiquotes from payload bodies (#154 Phase 2)#182
defangdevs merged 5 commits into
masterfrom
refactor/phase2-purge-antiquotes

Conversation

@defangdevs

Copy link
Copy Markdown
Owner

Phase 2 of #154, stacked on #181 (Phase 1); retarget after it merges. This is the content-review PR of the series: every modules/src/ payload becomes a genuine, backend-neutral file — real shell/python/markdown/Caddyfile, zero ${pkgs.*}/${cfg.*} — and the committed tests/golden/ diff in this PR is the exact rendered-behavior delta for review.

Conventions applied (from the issue)

  • Binaries from unit PATH: attach script (ttyd unit gains tmux), webhook spawner (daemon unit gains jq/coreutils/agent-box-session), spot monitor (unit gains curl tmux jq util-linux systemd coreutils), supervisor/CLIs use jq/tmux/bash already on the agent PATH.
  • AGENT_BOX_*_BIN env where a binary is deliberately not on the agent PATH: grep/find (transcript lookups), hostname (codex UTS re-exec). Keeping them off the agent-visible PATH avoids a behavior change for sessions.
  • AGENT_BOX_* config env: unit environment for daemons (spot monitor's USERS/GRACE/POLL/MSG — with %%% escaping, a hazard the baked-in path never had); a two-line generated wrapper exports config for the user-invoked CLIs.
  • Supervisor is now ONE user-independent script (agent-box-supervisor replaces per-user agent-box-<user>-start). Its whole per-user contract is unit env: AGENT_BOX_SESSIONS_SEED, AGENT_BOX_AGENT_BINS (name=path pairs replacing the generated case arms), AGENT_BOX_HOST_LABEL, AGENT_BOX_ENV_EXEC, AGENT_BOX_CODEX_RC, AGENT_BOX_AGENTS_POINTER, AGENT_BOX_WEBHOOK_REPO (doubles as the webhook flag). This is precisely the surface Phase 3's agent-box@.service template unit + drop-ins will consume. env-exec and attach are likewise now singletons.
  • Caddyfile fragments are plain @TOKEN@ templates (@USER@, @USER_ENV@, @SETTINGS_SOCKET@, @DOWNLOADS_DIR@, @TTYD_PORT@, @WEBHOOK_PATH@, @WEBHOOK_SOCKET@, @DOMAIN@) bound by one replaceStrings each — the placeholder contract the Phase 4 native renderer reuses. Rendered Caddyfile is byte-identical.
  • default AGENTS.md is plain markdown + @WEBHOOK_SECTION@; rendered guides byte-identical in both webhook-on and -off configs.
  • Security exception, on purpose: the password helper keeps HASH_FILE/COOKIE_FILE/ENV_SUFFIX/CADDY compiled into the store script (generated tail after the real-python body). It runs via sudo — env-supplied paths would hand the caller a root-write redirect primitive.
  • update.sh kept its pre-existing REPO/CURRENT_REV/PIN_FILE env names (unit-internal; renaming would be churn). The webAuthSecretsService script body (a per-user eval-time loop) is deferred to the Phase 3 unit redesign, where it needs restructuring anyway.

Verification

  • All src scripts pass bash -n / py_compile; flake8 still gates the assembled settings daemon & password helper via writePython3Bin.
  • Byte-identical where no change was intended: Caddyfile, AGENTS.md guides, sudoers, tmpfiles, update script, webhook/settings sockets.
  • Green: golden-snapshot (fixture regenerated & reviewed), module-generated-up-to-date, multi-user, module-single-file, download-route, webhook-route (aarch64). The x86_64 VM tests in CI (sessions, webhook, settings-page, download-files, self-serve-domain) execute all converted scripts end-to-end.

Third checkbox of #154.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NPHNnT2DsWHLhbbhsq2Wc7

@defangdevs

Copy link
Copy Markdown
Owner Author

Phases 0 and 1 are on master now (#179 9e3401b, #181 pending its post-retarget run), so this is the last of the stack. I got the rebase mechanically sound but stopped short of resolving it, because finishing it means re-authoring behaviour-bearing scripts and I'd rather you make that call than have me guess. Everything I established, so the next attempt doesn't start cold:

Rebase recipe. A plain git rebase replays the old Phase 0 commit on top of the new one and conflicts in all 15 fixture files. Use --onto instead:

git rebase --onto <new-phase1-tip> 9645ae2

That reduces it to 4 payload files plus modules/agent-box.nix (generated — discard and re-assemble) and three UD fixture entries that are just this PR's payload renames (agent-box-agent-start → supervisor).

The real conflicts — master changed these four bodies after this PR was written:

file hunks conflicted lines what arrived on master
src/supervisor.sh 1 275 codex re-auth (#180/#191)
src/webhook-cli.sh 3 84 --when/--drop help (#199)
src/default-agents.md 2 69 guide edits
src/webhook-spawn.sh 1 55 ownership seeding (#194)

Phase 2 dialect, confirmed from bin/assemble-module.py and this PR's own files — a resolution has to do all four, not just the antiquotes:

  1. Dedent the body to column 0 (the marker flips @@include-verbatim:@@include:, which re-indents from col 0; verbatim preserved indentation, so this is easy to miss).
  2. ''${X}${X}.
  3. ${pkgs.coreutils}/bin/id → bare id. Safe because the webhook unit sets path = [ pkgs.jq pkgs.coreutils sessionCli ] — that PATH is load-bearing for every bare binary the purge introduces.
  4. $JQ=jq is the established convention for jq.

Why I stopped. escaper_for() keys off the file suffix, so .sh/.md/.py get the identity function rather than nix_escape — I'd want that contract pinned down before trusting any resolution, since getting it wrong changes what the box runs rather than failing loudly. And unlike Phase 1 there's no byte-identity oracle here: this PR's fixture delta is intentionally non-empty, so update-golden can show me a delta but can't tell me the delta is right. The VM tests are real but partial coverage. Merging four re-authored behaviour-bearing scripts on that basis is a bigger call than the other five PRs needed, and it's yours.

Verification available once resolved: nix run .#assemble (both work on the box — update-golden takes ~20 min here), then nix build .#checks.aarch64-linux.{golden-snapshot,module-generated-up-to-date}, then read the fixture delta as the rendered-behaviour change it's meant to be.

Happy to push through if you want it — say the word and I'll resolve all seven hunks and let the VM tests arbitrate. Also worth noting: #199's webhookPolicyApply stays inline in .nix.in (it's not in modules/src/), so it's outside this PR's stated "no antiquotes in src/" scope — a Phase 2 follow-up rather than something to fold in here.

@defangdevs
defangdevs changed the base branch from refactor/phase1-deinline-payloads to master August 12, 2026 00:12
@defangdevs
defangdevs force-pushed the refactor/phase2-purge-antiquotes branch from 6725fa9 to 9f9c164 Compare August 12, 2026 20:31
@defangdevs

Copy link
Copy Markdown
Owner Author

Rebased and resolved. Head is 9f9c164, base master (43e6adc), state CLEAN, and CI is green on that SHA (run 31638115190 — cross-checked headSha against headRefOid, not the pre-rebase green).

The --onto recipe held: 4 payload conflicts + the generated .nix + 3 UD rename entries, no fixture blowup.

Two corrections to the recipe I left last time, both load-bearing:

  1. The escaper_for() worry was my misreading — and it was the thing I said I wanted pinned down before trusting any resolution. resolve() calls escaper_for(path) on the host file, then applies it to each child payload; the child's own suffix never selects an escaper. So every src/* payload included from agent-box.nix.in (.in) does get nix_escape. Identity applies only to settings.css/settings.js nested inside the .py host, which is correct — the whole .py is escaped once at the .in boundary. Confirmed in the output: script-side refs render ''${AGENT_BOX_GREP_BIN:-grep} while the unit environment block uses real antiquotes.
  2. "Dedent to col 0" is per-file, not universal. webhook-spawn.sh keeps its 4-space indent because its marker sits at col 0; supervisor/webhook-cli/default-agents dedent because theirs moved to a 4-space column. Blanket-dedenting would have shifted the rendered bodies.

A bug this found, which is the one worth reviewing: codex-remote-control.sh auto-merged without a conflict and carried master's Phase 1 ''${reason#...} (from the codex re-auth work) into a file whose marker had already flipped to the escaping @@include:. nix_escape escaped it a second time, so the rendered script got a literal ''${reason#...}. Bash concatenates the empty '' and still expands — no test would have failed, it would have shipped as invisible cruft. Only the regenerated fixture showed it. Fixed in 9f9c164.

Worth noting the near-miss in tooling too: a combined grep "\${pkgs\.\|...\|''\${" reported clean over modules/src/. grep -F -e "''\${" is the form that catches it, and it's what the invariant should be checked with.

Resolutions

file how
supervisor.sh Phase 2 base + master's five -- end-of-options guards; delta mirrors master's exactly
webhook-cli.sh master's side dedented 4; cross-checked both directions
default-agents.md hunk 1 master's side; hunk 2 was a mis-alignment (master's webhook text aligned against this PR's caddy text) — kept this PR's, and ported master's webhook edits into the split-out default-agents-webhook.md
webhook-spawn.sh master's side, ${pkgs.coreutils}/bin/{id,date,mv,rm} → bare (unit path is [ jq coreutils sessionCli ])

On "no byte-identity oracle" — there is a partial one, and it passes. The rendered AGENTS.agent.md, AGENTS.robot.md and caddy_config are byte-identical to master, which independently validates the markdown split-and-port (the one resolution with no VM coverage at all). The intentional fixture delta is then only: the -- guards reaching the supervisor, the bare-binary purge reaching webhook-spawn, and codex-remote-control back to master's text modulo the intended purge.

Also green locally: all six aarch64 checks (golden-snapshot, module-generated-up-to-date, multi-user, module-single-file, download-route, webhook-route), bash -n / py_compile on every payload, and a full AGENT_BOX_* cross-check showing no unset required var (WEBHOOK_SCRIPT exported by the generated wrapper, CODEX_UTS self-set on re-exec, HOOK_SESSION_MAX defaulted :-4).

Two things I did not fold in, both deliberate:

Not merging: this needs your review, and the fixture delta is the thing to read.

@lionello

Copy link
Copy Markdown
Collaborator

@defangdevs rebase

lionello and others added 4 commits August 12, 2026 16:17
… Phase 2)

Every modules/src/ payload is now a genuine, backend-neutral file — real
shell/python/markdown/Caddyfile with no ${pkgs.*}/${cfg.*} — spliced via
the escaping @@include:@@ marker. Values reach the scripts per the issue's
conventions:

- binaries: bare names on the owning unit's PATH (ttyd unit gains tmux;
  the webhook daemon unit gains jq/coreutils/agent-box-session; the spot
  monitor unit gains curl/tmux/jq/util-linux/systemd/coreutils), or
  AGENT_BOX_*_BIN unit env where deliberately NOT on the agent PATH
  (grep/find, hostname).
- config: AGENT_BOX_* env — on the unit for daemons, exported by a
  two-line generated wrapper for the user-invoked CLIs (agent-box-session,
  agent-box-webhook).
- the supervisor is ONE user-independent script (agent-box-supervisor):
  per-user values (sessions seed, agent→binary map, host label, env-exec
  wrapper, codex-rc wrapper, AGENTS.md pointer, webhook repo/flag) are now
  its unit-environment contract — the exact surface Phase 3's template
  units + drop-ins need.
- Caddyfile fragments are plain @token@ templates bound by one
  replaceStrings call each — the placeholder contract for the Phase 4
  native renderer. Rendered Caddyfile is BYTE-IDENTICAL.
- default AGENTS.md is plain markdown with a @WEBHOOK_SECTION@ token;
  rendered guides are BYTE-IDENTICAL.
- password helper keeps its per-user paths COMPILED IN (sudo boundary —
  env-supplied paths would be a root-write redirect primitive); they moved
  to a generated tail after the real-python body.

Byte-stable where required: Caddyfile, AGENTS.md guides, update script,
sudoers, tmpfiles all identical. The reviewed golden diff is confined to
unit env/PATH additions and the script bodies themselves (store-path
references → PATH/env lookups).

All src scripts pass bash -n / py_compile; eval checks green
(golden-snapshot, module-generated-up-to-date, multi-user,
module-single-file, download-route, webhook-route).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPHNnT2DsWHLhbbhsq2Wc7
…bel in tests

The Phase 2 rename to one shared agent-box-supervisor script broke the
settings daemon's /proc argv scan (it looked for agent-box-<user>-start),
so the page's "restart" button silently degraded from a unit bounce to a
session bounce — caught by the settings-page VM test's MainPID-change
wait, exactly what that assertion exists for. The scan's own-uid
restriction is what scopes the now-shared name to the right unit.

sessions.nix likewise asserted the old baked-in literals; the host label
now rides the unit environment and the Remote Control name derives from
$USER, so assert those.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPHNnT2DsWHLhbbhsq2Wc7
…n the miss

The Phase 2 rename to the shared agent-box-attach left the sessions
test's grep -o matching nothing; the empty substitution turned
`grep -q '-T hyperlinks'` into a stdin read on the backdoor shell, which
hung the run to the 25-minute CI cap (conclusion "cancelled", no
assertion output). Point it at the new name and guard the substitution
with `|| echo /missing` + head -n1 so a future rename fails in seconds
instead. Footgun filed as #183.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPHNnT2DsWHLhbbhsq2Wc7
Rebase resolution for the four bodies master changed after this PR was
written: the `--` end-of-options guards in supervisor.sh (#180/#191), the
`--when`/`--drop` help in webhook-cli.sh (#199), the ownership seeding in
webhook-spawn.sh (#194), and the guide edits in default-agents.md (whose
webhook half now lives in default-agents-webhook.md).

Also unescapes two lines in codex-remote-control.sh that the rebase merged
WITHOUT a conflict. Its marker had already flipped to the escaping
`@@include:` form, so master's Phase 1 `''${reason#...}` was escaped a
second time and rendered as a literal `''${reason#...}`. Bash concatenates
the empty `''` and still expands, so nothing would have failed a test —
exactly the silent-render class of bug Phase 2 has no byte-identity oracle
for. Found by reading the regenerated fixture, which is what that diff is
for.

Golden fixture regenerated: the rendered delta is the `--` guards reaching
the supervisor, the bare-binary purge reaching webhook-spawn, and
codex-remote-control returning to master's rendered text.
@defangdevs
defangdevs force-pushed the refactor/phase2-purge-antiquotes branch from 9f9c164 to 55e2be5 Compare August 12, 2026 23:19
@defangdevs

Copy link
Copy Markdown
Owner Author

Rebased onto master (4 commits kept). The branch is mergeable again.

Master moved 8 commits ahead and touched the same payload bodies this PR
rewrites, so I carried master's behavior into the Phase 2 dialect:

  • supervisor.sh — the session-park epilogue (issue 167). ${markStopped name}
    is a Nix antiquote, which Phase 2 does not allow in a payload body, so the
    unit now passes the path as AGENT_BOX_MARK_STOPPED and the script reads it
    from the environment. This keeps the script user-independent.
  • session-cli.sh — the stop verb, the revive behavior of restart, and the
    stopped state in ls.
  • attach.sh and session-cli.sh — the mascot (issue 185).
  • default-agents.md — master's new prose, plus the two new sections about
    slash commands and session names. The webhook part goes to
    default-agents-webhook.md behind the @WEBHOOK_SECTION@ token, as before.

modules/agent-box.nix and tests/golden/ are generated, so I did not merge
them by hand. I regenerated both with nix run .#assemble and
nix run .#update-golden.

One real fix came out of the regeneration: the committed golden had
''${reason#...} in the codex remote-control payload. That is a leftover Nix
escape. The payload now has plain ${reason#...}, which agrees with master.

These checks pass locally: module-generated-up-to-date, golden-snapshot,
module-single-file, multi-user. This box has no /dev/kvm, so I let CI run
the VM tests. The sessions test covers all four parts of the merge above.

Comment thread modules/src/caddyfile-header.caddy
Comment thread modules/src/session-cli.sh Outdated
Comment thread modules/src/webhook-spawn.sh Outdated
Review feedback: an indented standalone file is weird. session-cli.sh,
webhook-spawn.sh and codex-remote-control.sh still carried the 4-space
indentation they had while they sat inside a Nix indented string, so they
did not read as the real scripts Phase 2 makes them.

Dedent the code and let the @@include@@ marker carry the indentation, as
the already-converted payloads do. Two kinds of line keep their exact
column, because there the column IS the value:

  - heredoc bodies and their terminator (codex-remote-control.sh) — that
    text is what the pane prints;
  - lines already at column 0 — continuations inside multi-line shell
    string literals (the webhook spawn preamble, session-cli's
    newline-in-a-case-pattern).

Whitespace only: every rendered payload is byte-identical to before once
leading indentation is stripped, and the heredoc message text does not
move at all.

Also answer the "missing a }?" question in caddyfile-header.caddy: the
fragment ends inside the vhost block on purpose. Say so where the
question came up; the rendered config balances 71/71.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3WmpdY3zc555sNdkY9dzQ
@defangdevs
defangdevs merged commit f154467 into master Aug 12, 2026
1 check passed
@defangdevs
defangdevs deleted the refactor/phase2-purge-antiquotes branch August 12, 2026 23:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants