Repository navigation
Development
CI behavior when working on agent-box itself. Repo conventions — structure,
build and test commands, commit style — live in AGENTS.md at the repo root,
so this page does not repeat them.
Host-specific setup is deliberately absent. How a given box puts tools on
PATH, sets a git identity or stores a gh token is a property of that box,
not of the template, so it belongs in that host's own config repo (for the
maintainer's box, a private one).
The box's update source tree is root-owned. On NixOS with self-update enabled, one agent user also gets a checkout parked at the exact running revision. That checkout lets an agent inspect the code and iterate locally without letting an agent-writable tree become the root build input. The native backend has not yet gained that agent-owned checkout; it still has the root-owned update tree.
Creating a fork in the user's GitHub account is a separate decision. Since
PR #785, NixOS defaults
selfUpdate.checkout.fork to false; native also creates no fork by default.
An operator who wants the NixOS checkout bootstrap to create a fork remote
can set that option to true. Existing fork remotes are not deleted when the
default changes.
- Watch runs in the FOREGROUND:
gh run watch <id> --exit-status. Don't background a streaming watch — it leaves stale "Running" ghost tasks in the UI. - CI only runs on build-relevant paths (including Nix, payloads, tests, scripts, the lockfile and the CI workflow); doc-only pushes skip it.
- The landing page (
docs/) publishes via GitHub Pages branch source, not a workflow — a push tomasterthat touchesdocs/deploys on its own. -
modules/agent-box.nixis generated. After you editmodules/agent-box.nix.inormodules/src/, runnix run .#assembleand commit the result, or themodule-generated-up-to-datecheck fails on drift.
Implemented in PR #671 and rebalanced in PR #861, which record baseline measurements, validation runs and the compute tradeoff.
CI runs native checks alongside five VM lanes on standard runners. Each VM lane prepares only its own drivers before executing one test at a time, so at most five VM tests run per workflow run. A test can start multiple guest machines. The extra runners reduce the critical path at the cost of more total runner minutes; do not compare only the fastest job or silently raise VM concurrency.
The long session test evaluates as two independent checks from the same
tests/sessions.nix: sessions covers harness, CLI and registry-race behavior,
while sessions-lifecycle covers state, profiles, parking and crashes. They
share the exact guest definition in tests/sessions-common.nix, so only the
test scripts and guest boots are duplicated. The short session-limit test
runs serially in the webhook lane's existing slack rather than paying for a
sixth checkout and driver-preparation job. In PR #861 this changed the measured
critical path from 10:50 to 8:33, while summed non-gate runner time moved from
35:44 to 37:37. The unchanged browser lane became the bottleneck at 8:27.
Validate module & VM is the final gate. It runs even when a dependency fails
and succeeds only if native validation and every VM lane succeeded. The
matrix keeps running sibling lanes after a failure so their results are not
lost. For performance measurements, count from the first validation job's
start through the final gate's completion, not the short gate alone. Record
initial queue time separately, per-lane preparation and VM execution times,
and the sum of job durations to expose the compute tradeoff.
Native check discovery and VM lane inventory checks replace the old manual
workflow check list. Add native checks to the flake; register VM tests in
vmTestsFor and assign each exactly once in ciVmLanes. The regression check
also compares that schedule with the workflow matrix and checks the final
gate's handling of failures, cancellations and skipped jobs.
Moving VM tests onto less busy runners exposed races in fixtures that injected
state while a real CLI was still starting. The rotation fixture's synthetic
SessionStart event could be overwritten by the real startup hook; the notice
fixture's synthetic transcript could change during a resume. The
quiesce_harness helper in tests/sessions.nix replaces the pane with an idle
process and waits for the old process group to disappear before supplying
synthetic state. Keeping the session present prevents the supervisor from
respawning it early. The test then kills the pane and checks the real
supervisor's next command, preserving both rotation hops and all notice cases.
On a memory-constrained development host, build the complete discovered
native-check inventory one check at a time if the aggregate's evaluation
causes swapping. Avoid overlapping large Nix evaluations. This changes the
peak memory use, not the required coverage: keep every native check and
continue through failures before assessing the result. In the email-purpose
address validation, sequential builds with GC_FREE_SPACE_DIVISOR=10
completed the same aarch64 inventory that was impractical as a concurrent
aggregate evaluation.
Introduced by PR #728; activation is pending project and repository credential configuration.
.github/workflows/sonarqube.yml runs separately from the Nix/VM workflow on
pushes to master, same-repository pull requests, and manual dispatch. It has
no path filter, so changes to analysis settings and any supported source file
are analyzed. Fork pull requests and Dependabot runs skip the job because they
cannot use the repository scan secret. Do not use pull_request_target to
bypass that boundary.
Before enabling the workflow, import this repository into SonarQube Cloud (the
default) or create a project on an existing SonarQube Server accessible to
GitHub-hosted runners. Set its main branch to master. For Cloud, disable
Automatic Analysis in the project's Administration > Analysis Method so CI
owns analysis. Use a plan/edition that supports pull-request analysis.
Configure these in the repository's Actions secrets and variables:
- Secret
SONAR_TOKEN: a token with Execute Analysis permission for the project. - Variable
SONAR_PROJECT_KEY: the project's actual key from SonarQube. - Variable
SONAR_ORGANIZATION: the organization key, required for Cloud; leave it empty for Server. - Variable
SONAR_HOST_URL: optional; defaults tohttps://sonarcloud.io. Set this to the existing Server URL when using SonarQube Server.
Run the workflow on master first to establish the baseline before reviewing
PR results. Missing configuration fails explicitly. The scan waits up to five
minutes for the quality gate; a failed gate, timeout, or scanner error fails
SonarQube analysis. This is separate from Validate module & VM; adding a
workflow does not change GitHub branch protection. Only require the analysis
check after configuration and the baseline scan succeed, accounting for the
fork/Dependabot skip policy.
sonar-project.properties separates maintained source from tests and excludes
the assembled Nix module, vendored code, generated Azure ARM JSON, and rendered
test snapshots. The extensionless bin/agentbox is included as Python.
Analysis covers languages supported by the configured SonarQube service;
existing Nix checks and VM tests remain necessary. This integration does not
generate test coverage reports, so choose the project's quality gate with that
in mind rather than treating the scan as coverage validation.
The scanner action is pinned to an immutable release commit. See the official action for its configuration and supported scanners.
PR #980 changes
sessionLimit into a budget for running or queued event-created hook sessions.
Manual CLI/UI sessions, sign-in/pairing sessions and operator shells bypass
that budget, including supervisor recovery after reboot. A hook event must not
hibernate a user's own conversation to make room for background work.
Capacity counting and the session-list admission states share the policy in
modules/src/lib/session-capacity.py. Recorded origin: webhook identifies a
hook; the hook- prefix is the fallback for older entries without provenance.
An explicit non-webhook origin takes precedence over the prefix.
Only hooks can be hibernated, and the existing transcript, lease, subscription and pending-delivery checks must still prove quiescence. Hibernation preserves an ephemeral hook's registry entry and exact native conversation mapping. Explicit stop clears the hibernation marker and allows ordinary ephemeral cleanup. Keep both the initial reaper scan and its locked recheck consistent.
The agent sudo exception and settings-page update trigger remain restricted to the upstream default branch, as decided in PR #558. An operator with a root console can separately choose to execute an unmerged revision on a disposable test box. This does not call for a broader agent grant.
PR #990 proposes a standalone
scripts/test-box-update.py helper for that operator workflow. It resolves the
candidate to an immutable commit, pins the original runtime closure against GC,
and retains the original revision until an explicit restore succeeds. The helper
stays outside the profile it replaces and uses the original pinned updater for
recovery. Its usage guide is kept in docs/test-box-updates.md with the code.
This native-profile workflow does not provide a NixOS system rollback, undo data
changes, or automatically revert an unreachable box. Keep an operator recovery
path available throughout the trial. Automatic rollback/boot recovery remains
separate work under #676.
PR #996 proposes a mandatory exact-domain SPF-or-DKIM gate for inbound email. SPF needs the actual SMTP peer and envelope, and a rejection must happen before SMTP 250 transfers transport responsibility. Consequently the decision belongs in an OpenSMTPD filter, not in the MIME adapter or receiving agent. Permanent failures use 550; temporary DNS/verifier failures use 451 so the sending MTA can retry.
The delivery adapter also checks the filter's server-generated persistent
marker. Replacing incoming copies prevents forged headers, and generating the
value only when the filter is installed prevents pre-upgrade queue entries
from supplying it in advance. Preserve /var/lib/agent-box-email alongside
the SMTP spool so accepted messages remain deliverable after restarts.
Authentication establishes a domain, not permission to execute an email's
instructions. See the email guide
for the exact acceptance policy and upgrade behavior.
Every enqueue path needs the filter, including listen on socket: OpenSMTPD's
implicit local listener otherwise bypasses TCP filters. Local sendmail has no
remote SPF peer and requires aligned DKIM. The persistent marker is readable
by recipient UIDs and records transport provenance; it is not an authorization
secret that can replace filtering local submissions.
The filter protocol has different bounds in each direction. Incoming SMTP DATA plus framing can exceed 64 KiB, so the Python reader allows 128 KiB. OpenSMTPD's processor-response reader itself buffers at most 65536 bytes; returning an oversized frame stalls delivery. DATA lines above 64000 bytes are therefore drained without echo and rejected at commit. The VM checks rejection followed by authenticated transactions on the same connection.
OpenSMTPD also starts the filter with nonblocking stdout. A burst of small headers can exhaust pipe capacity even when every frame fits. Keep unwritten bytes after partial writes, wait for writable readiness on EAGAIN, and drain after each input frame and each independently completed verdict. This bounds output buffering and avoids aborting the filter under backpressure. A real pipe regression forces backpressure and verifies byte-for-byte output.
The flake lock and an EC2 AMI's NixOS channel are independent inputs. A green build against the flake's CA package does not prove that a refreshed AMI can build the same explicit distrust list. In PR #1000, NSS 3.131.1 had already removed three emSign roots, and the strict CA builder rejected their now-absent blacklist entries before amazon-init could activate the box.
nix/tls-blacklist.nix keeps the explicit removal on older bundles and omits
only roots already removed by the selected NSS version. Both backends use the
same function, embedded into the standalone module by the assembler. The
tls-trust-modern native check supplies the exact newer certdata to both the
actual runtime and a NixOS module configuration; both must reject all twelve
compromised-chain fixtures with a nonempty trust store. Its NixOS fixture uses
the compatible PEM format so it can also assert root absence, rather than
confusing auxiliary distrust records with trusted roots.
SSM inline stdout is capped. Put a bounded journal tail ahead of ancillary
diagnostics; an unbounded derivation list can otherwise consume the response
before the actual build failure. The deployment workflow's diagnose_run
input reads the active IPv4 E2E instance's journal without creating another
instance or waiting for the deployment retry to finish.
PR #1002 implements
explicitly registered purpose addresses in place of unrestricted recipient
tags. The implementation is awaiting merge.
Each address has a purpose, worker profile and batching window, managed
through Settings or agent-box-email addresses without rebuilding or
restarting the mail service. Exact-topic local-webhook watches remain the
only dispatcher; an idle address does not require a resident worker.
The root configuration still controls eligible Linux users and domains.
Runtime registry directories under /var/lib/agent-box-email/addresses/
have a root-owned parent and per-user setgid <user>:smtpd directories.
The unprivileged SMTP filter reads only bounded, validated, non-secret
metadata, while the user's Settings/agent services receive write access
only to their own registry directory. Do not give the filter access to
private webhook state or credentials to implement recipient policy.
Registering installs a handler before enabling SMTP acceptance. Updating first defers new reception with SMTP 451, so an incomplete handler change stays closed without permanently bouncing mail. Disabling reception preserves its handler for already accepted mail; the MDA does not reapply a later recipient whitelist to its durable queue. Unknown addresses fail at RCPT with 550, while unreadable registries defer with 451. The existing mandatory exact-domain SPF-or-DKIM gate remains.
Addresses use the bus's case-insensitive identity. Do not allow two case variants to install different workers for one topic. Existing watches do not imply reception permission after upgrading; every intended address, including any untagged address, must be explicitly registered. See the email guide for commands and migration behavior.