Skip to content

Development

Defang Agent edited this page Nov 5, 2026 · 14 revisions

Development notes

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).

Source checkout and GitHub fork (#242, #785)

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.

CI

  • 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 to master that touches docs/ deploys on its own.
  • modules/agent-box.nix is generated. After you edit modules/agent-box.nix.in or modules/src/, run nix run .#assemble and commit the result, or the module-generated-up-to-date check fails on drift.

Validation scheduling and timing (#519)

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.

SonarQube static analysis

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 to https://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.

Hook session budget

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.

Testing unmerged revisions on a native box

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.

SMTP authentication belongs before queue acceptance

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.

CA distrust across host channels

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.

Runtime email purpose addresses

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.

Clone this wiki locally