phase: 24_dev_mtls_rollout

**Phase 24 final verification — all green**

- **Verified (read-only re-run, 62/62 PASS)** via `data/logs/verify-24-mtls.py`: nine sources `ok`; `/api/certs` all three `active` with `deployed_bundle_rev == bundle_rev == 2`; `agent.tls: true` + `agent.version 1.1.0` on every host; driveripper 16/16 drives live; gateway absent from `/disks` but `disks:gateway ok`; all served target URLs `https://`, no userinfo; pre-flip backup present; no transport/cert alert.
- **Criterion 1 (export):** `data/certs/{driveripper,deskwork,gateway}.bundle` re-checked byte-identical (sha256) to `GET /api/certs/<name>/bundle`, mode `0600`, under gitignored `data/`; live arms re-probed (issued→file, unknown/`issued:false`→one-line note + no file, wrong password→exit 2, nothing written).
- **Criterion 2 (`--fetch-bundle`):** 54 sandbox pins in `tests/test_deploy_scripts.py` pass — byte-identical `0640` install + TLS unit lines, refusals (unissued / 401 / non-bundle body / dead dashboard) before `need_root` with no write, token absent from token file, unit, stdout and sudoers hint.
- **Criterion 3–4:** `./scripts/dev-server.sh status` nine `ok` + bundles block; screenshots `data/logs/24-task04-{certs,disks,system}.png` exist.
- **Criterion 7:** no behavior change — `agent/` untouched, `src/` diff is a `certs.ensure_certs` docstring only; token-mode, revocation-before-dial and bundle-route tests green.
- **Tests/lint:** `bash .agents/validate.sh` → **EXIT=0**; `uv run pytest --cov --cov-fail-under=94` → **1542 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `uv run pytest -m live --live` → **11 passed**.
- **Docs:** README.md (all 1144 lines) + AGENTS.md reviewed against the live project — every config.py env var is in README and both templates, route set / severity thresholds / test counts / coverage figure match, all repo paths, cited test names and `Dash.*`/`DashCharts.*` helpers resolve, phase-24 doc pins green. **Verified consistent — no changes needed.**
- **Notable:** left `fastapi 0.153.0 → 0.158.0` (validate reports it as advisory; no dependency was touched, rule 8 conditions the check on deps moving). Killed two abandoned `/tmp/fbtest` loopback stub processes left by task 02's manual probe; dev server unaffected.
- **Next pending phase:** `.agents/phases/todo/25_masthead_control_placement/`.
This commit is contained in:
2026-10-21 13:47:45 -04:00
parent 5f3ed9bcd4
commit 89d9fb9f89
36 changed files with 2378 additions and 146 deletions
@@ -0,0 +1,36 @@
# Phase 24 — Dev mTLS: no non-mTLS agent, ever
**Source:** `TODO.md` L3 — "The client should never allow non-mtls communication. deskwork and gateway are still non-mtls according to the dashboard. During dev, the dev server should generate certs in a non-committed directory and then distribute them to clients during registration so we never need non-mtls servers."
**Story:** n/a
**Context:** phase 15 (complete) — the durable CA in `meta` (`certs.py`: five `certs.*` rows, the bundle stored verbatim), the agent's TLS mode (`agent/host_agent.py` 1.1.0: `--cert-bundle`, `CERT_OPTIONAL`, the live `# REVOKED` denylist, `agent.tls`/`agent.bundle_rev` in the snapshot), the collector's certificate dial (`collectors.py` `AgentTls` — an `https://` `HOST_TARGETS` entry dials with this app's client certificate), the bundle download route (`GET /api/certs/<name>/bundle`, session-gated, the stored bytes verbatim), the installer's `--cert-bundle` shape check (`deploy/install-agent.sh` lines 270–331 — the python3 validator runs **before** `need_root`, the bundle installs `0640 root:dashboards-agent` at line 385, the `--status` https probe), and the fleet record (AGENTS.md gap 11: no bundle is installed on any host, `HOST_TARGETS` is `http://` for every host, the installed agents run 1.0.0 so **1.1.0 ships together with the bundle, never the bundle alone**; gap 10: driveripper's unit is reproducible only with the repo script's `--as-root --caps CAP_SYS_RAWIO CAP_SYS_ADMIN --force` line — the Oct-18 sudo-granted snapshot must not be run; deskwork's `ops` has no installer grant, so its install goes through the operator's root). `scripts/dev-server.sh` (the dev server: reads `.env.dev`, nohup `dashboards`, `/api/health` readiness probe, `status` subcommand) and `data/` (gitignored — the non-committed directory the TODO names)
## Objective
The dev loop stops producing `http://` agents. On start, the dev server exports every configured host's stored bundle to non-committed `data/certs/` (the CA is already durable in `meta` — "generate" means *materialise what the first boot minted*, never a second authority), and the installer gains `--fetch-bundle` so a client's **registration** — its install — pulls its bundle from the dashboard and installs it in the same run (the operator no longer curls a file across the network by hand). Then the fleet itself moves: agent 1.1.0 + bundle on deskwork, driveripper and gateway, `HOST_TARGETS` flipped to `https://` host by host (a URL flipped before its bundle is installed turns that host red by construction — the order is the safety), leaving the dashboard's nine sources `ok` with three `agent.tls: true` hosts and no `http://` agent URL anywhere.
## Dependencies
- — (none) — the phase builds on complete phase 15 and phase 17; it is scheduled last on purpose, because it is the fleet-changing one.
## Tasks
1. `01_dev_bundle_export.md` — `scripts/dev-certs-export.py` + `dev-server.sh`: the dev server writes every configured host's bundle to `data/certs/<name>.bundle` (`0600`) on start; an unissued host says so and writes nothing
2. `02_installer_fetch_bundle.md` — `install-agent.sh --fetch-bundle <url> --cert-name <name> --session <token>`: registration pulls the bundle from the dashboard, the shape check runs on the fetched bytes before `need_root`, the token never reaches the token file or the unit
3. `03_fleet_rollout.md` — agent 1.1.0 + bundle on the three hosts (deskwork via the operator's root, driveripper with the gap-10 `--force` line, gateway with its sudoers grant), `HOST_TARGETS` flipped per host, the URL never flipped before the bundle
4. `04_verification.md` — the fleet is mTLS: `agent.tls`/`deployed_bundle_rev` agree for all three hosts, nine sources `ok`, no `http://` agent URL, the 16-driver SMART reads still live, screenshots
## Testing & Quality
- Unit/integration: `tests/test_deploy_scripts.py` — the export helper against a `create_app(...)` hosted on a socket (the sandbox pattern the installer tests already use: fake `install`/`chown` where the installer runs); the `--fetch-bundle` legs (a good bundle fetched, installed and byte-identical; a malformed one refused **before any write**; the token absent from every written artefact)
- Coverage: **>90%** on new/modified code
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's)
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. The README's *Protecting an agent, host by host* rollout becomes the `--fetch-bundle` recipe (the manual curl is demoted to the fallback), the dev section gains the `data/certs` export, AGENTS.md's fleet record and gap 11 resolve to "all three hosts mTLS since <date>" with the exact installed units, and the "bearer-token transport is the compatibility path, not the designed one" sentence becomes history. **No token, no PEM and no session value go into any tracked file** — the exported bundles live in gitignored `data/`, the fleet's `HOST_TARGETS` URLs carry no userinfo once flipped (invariant 6), and the task transcript names hosts, not credentials.
## Completion Criteria
- [ ] `./scripts/dev-server.sh restart` leaves `data/certs/<name>.bundle` for every configured host that has an issued bundle, `0600`, byte-identical to the stored row, and a one-line note for any unissued host (pinned + verified)
- [ ] `sudo deploy/install-agent.sh --fetch-bundle <url> --cert-name <host> --session <token> ...` installs a byte-identical bundle in the sandbox, refuses a malformed one before `need_root`, and leaves no token in the token file, the unit or the log (pinned)
- [ ] all three hosts run agent 1.1.0 from an installed bundle, `HOST_TARGETS` is `https://` for every host with no userinfo, and `./scripts/dev-server.sh status` reads nine sources `ok` with `agent.tls: true` and `deployed_bundle_rev == bundle_rev` on all three (screenshots of `/certs` in `data/logs/`)
- [ ] driveripper's 16 drives still read live on `/disks` after the `--force` unit rewrite (the gap-10 unit shape is reproduced exactly)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the token-mode path, the revocation wiring, the bundle route and the agent's token-mode bytes are all unchanged
@@ -0,0 +1,27 @@
# Task 01 — The dev server exports the bundles to `data/certs/`
**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "During dev, the dev server should generate certs in a non-committed directory and then distribute them to clients during registration…"
**Story:** n/a
## Objective
On start, the dev server materialises the **stored** CA's per-host bundles in non-committed `data/certs/`: one `0600` file per configured host with an issued bundle (`data/certs/<name>.bundle`, byte-identical to the `meta` row the bundle route serves — the CA is durable in `meta`, so "generate" is an export of what the first boot already minted, never a second authority; `data/` is gitignored, so nothing committed ever carries a key). A configured host with **no** issued bundle (the invariant-3 shape: `issued: false`) writes no file and prints one line naming the host and the `/certs` route that issues it. The export is a small standalone script the dev server calls, so the test suite drives it against a `create_app(...)` hosted on a socket without a nohup'd process.
## Work
1. `scripts/dev-certs-export.py` — a standalone helper (stdlib `urllib`, the way `install-agent.sh`'s validator uses `python3`): arguments `--url` (the dev server's base URL), `--password` (the sign-in password — the dev server reads it from the same `.env.dev` it already loads, so the value comes from the environment, never a tracked file), `--names` (the configured host names, space-separated — read from `HOST_TARGETS`' names, the same parsing `parse_targets` does, passed in by the caller rather than re-parsed inside), `--out-dir` (defaults to `<root>/data/certs`). Behaviour: sign in via `POST /api/auth/login` (the public route; the token travels in `Authorization` for the bundle GETs), then per name `GET /api/certs/<name>/bundle` — a `{"configured": true, "issued": false}` answer writes **no file** and prints the one-line note; a configured host that 404s (`{"configured": false}`) is impossible by construction (names come from `HOST_TARGETS`) but is handled as the same no-file line, not a crash; a good bundle is written `<out-dir>/<name>.bundle` at `0600` (created via `os.open`, not `open()`, so the mode is the file's birth mode). The script exits 0 when every name resolved to either a file or the no-issued note, and non-zero on a sign-in failure — a dev server that cannot sign in says so in `dev-server.sh`'s start output, never silently.
2. `scripts/dev-server.sh` — in `start()`, after the readiness probe: call the export (`.venv/bin/python scripts/dev-certs-export.py --url http://127.0.0.1:$PORT --password "$DASH_ADMIN_PASSWORD" --names <the HOST_TARGETS names> --out-dir $ROOT/data/certs`), printing its per-host lines; in `status`, print the `data/certs/` listing (names + sizes) as the "bundles exported" block. The export failure is a **warning line**, not a `start` failure — a bundle export that cannot sign in is a dev-convenience failure, and the server itself is up (the readiness probe already proved it); the one line says what to check.
3. `tests/test_deploy_scripts.py` — the pin: host `create_app(settings with admin_password="testpass", pbkdf2_iterations=1000, one or two configured targets, an in-memory store with an issued bundle for one host and none for the other)` on `127.0.0.1:0` via `uvicorn` (the project's own dependency — `__main__.py`'s pattern), run the script against it, and assert: the issued host's file exists at `0600` and is **byte-identical** to `GET /api/certs/<name>/bundle` from the same app; the unissued host wrote **no file** and the output carries its one-line note; a wrong `--password` exits non-zero and writes nothing.
## Testing & Quality
- Unit/integration: the pin above (the sandbox pattern `tests/test_deploy_scripts.py` already uses for the installer)
- Coverage: **>90%** on this task's new/modified code
- Real-boot check: `./scripts/dev-server.sh restart` on the dev box prints the per-host export lines and `data/certs/` holds the files (the dev box is the test bed — invariant 10)
## Completion Criteria
- [ ] `./scripts/dev-server.sh restart` leaves `data/certs/<name>.bundle` per issued host, `0600`, byte-identical to the stored row; an unissued host is a one-line note and no file (pinned + verified on the dev box)
- [ ] `status` prints the exported-bundles block
- [ ] no bundle, key or PEM is ever written outside `data/` (the gitignored tree) — a grep of the tracked files for the export's output path confirms it lands only under `data/`
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] full test suite green
- [ ] no behavior change in completed work: the dev server's start/stop/status semantics, the readiness probe and the CA rows in `meta` are untouched (an export never mints — `certs.read_state` is the request-time read the bundle route uses, and the export dials that route, not `ensure_certs`)
@@ -0,0 +1,29 @@
# Task 02 — `install-agent.sh --fetch-bundle`: registration pulls the bundle
**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "…then distribute them to clients during registration so we never need non-mtls servers."
**Story:** n/a
## Objective
The installer's registration moment — the install — is where the client gets its bundle: `--fetch-bundle <dashboard-url> --cert-name <name> --session <token>` fetches `GET /api/certs/<name>/bundle` from the dashboard **in the install run**, the existing python3 shape check (lines 270–331, which runs before `need_root`) validates the **fetched bytes**, and the rest of the `--cert-bundle` path (the `0640 root:dashboards-agent` install at line 385, the unit's `--cert-bundle` + `RuntimeDirectory` lines, the `--status` https probe) runs on them unchanged. The session token is a **fetch credential only**: it never reaches the agent's token file, the unit, the log or the installed bundle, and the fetch happens before `need_root` (a host whose dashboard refuses the fetch never gets a partial install — the same order the bundle validator already has).
- ASSUMPTION: the session travels on **argv** (`--session <token>`), matching how the operator already signs in for the manual curl the README's rollout documents; the token is visible in the `sudo` process list for the fetch's duration, which is the accepted cost on a single-admin box (the alternative — a `0600` session file — is a second artefact to clean and the operator's muscle memory is the curl recipe). The three flags are **all-or-nothing**: any one without the others is refused with a usage line before `need_root` and before any write.
## Work
1. `deploy/install-agent.sh` — argument parsing (near line 136): `--fetch-bundle) FETCH_URL="$2"; shift 2 ;;`, `--cert-name) CERT_NAME="$2"; shift 2 ;;`, `--session) SESSION_TOKEN="$2"; shift 2 ;;`; the all-or-nothing check (the three set together or not at all; `--fetch-bundle` and a local `--cert-bundle <file>` together is refused — two bundle sources are a contradiction, pick one) in the same pre-`need_root` region the bundle validator occupies (lines 160–331).
2. `deploy/install-agent.sh` — the fetch: a `python3` `urllib` block (the script already requires `python3` for the shape check — no new dependency, no `curl` assumption): `GET $FETCH_URL/api/certs/$CERT_NAME/bundle` with `Authorization: Bearer $SESSION_TOKEN` written to a `mktemp` file; the response body's shape is checked in the same block — a `{"configured": true, "issued": false}` body is refused with its own one-line reason (*"no bundle issued for <name> — issue it from /certs first"*), a `404`/`401`/`403` is refused naming the HTTP status, and only a 200 with a non-empty body falls through to the **existing** shape check (lines 270–331) run on the temp file; on any refusal the temp file is deleted (`trap`-covered, the way the script already cleans its unit temp file) and nothing is written. On success the temp file is handed to the existing `BUNDLE_SRC` path — the `0640` install, the unit lines and the `--status` probe all run unchanged on the fetched bytes.
3. `deploy/install-agent.sh` — token hygiene: `SESSION_TOKEN` is used only in the fetch block; it is never `echo`d (the script's existing log lines name the URL, never the header), never written to `$TOKEN_FILE`/`$CERT_FILE`/the unit, and is `unset` after the fetch; the `--help` block gains the three flags with the one-line recipe (the URL is the dashboard's, the name is the `HOST_TARGETS` name, the session is the signed-in token).
4. `tests/test_deploy_scripts.py` — the pins (the sandbox pattern: fake `install`/`chown`, a local `http.server` thread serving the bundle route's exact bytes — the `application/x-pem-file` attachment body a `create_app` bundle route would serve): (a) a good fetch → the installed `CERT_FILE` is **byte-identical** to the served bytes, `0640`, and the unit carries the `--cert-bundle` line; (b) a served `{"configured": true, "issued": false}` body → refusal with the one-line reason, **no** `install` call (the fake records it), temp file gone; (c) a served 401 → refusal naming the status, no write; (d) the **token absence** pin: the token string appears in no written artefact — not the token file, not the unit, not the script's captured stdout (the test serves the bundle with a token it chose and greps the sandbox for it); (e) the all-or-nothing and the two-bundle-source refusals, both before `need_root` (the sandbox's root check records the call order).
## Testing & Quality
- Unit/integration: the five pin legs above
- Coverage: **>90%** on this task's new/modified code (the gate applies to the changed test file)
## Completion Criteria
- [ ] `sudo deploy/install-agent.sh --fetch-bundle <url> --cert-name <name> --session <token> --bind … --port …` installs a byte-identical bundle in the sandbox, `0640`, with the unit's TLS lines (pinned)
- [ ] a malformed/missing/401 bundle is refused before `need_root` with its own one-line reason and no partial write (pinned)
- [ ] the session token appears in no written artefact — token file, unit, or captured stdout (pinned)
- [ ] `uv run pytest` green, `bash .agents/validate.sh` green
- [ ] full test suite green
- [ ] no behavior change in completed work: the local `--cert-bundle <file>` path, the `--status` probe, the re-run guard (task-17's diff-and-refuse) and the token file's mode are byte-identical in behaviour to before
@@ -0,0 +1,30 @@
# Task 03 — The fleet goes mTLS, host by host
**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "The client should never allow non-mtls communication. deskwork and gateway are still non-mtls according to the dashboard."
**Story:** n/a
## Objective
All three agents run 1.1.0 from an installed bundle, and `HOST_TARGETS` is `https://` for every host. The order per host is the safety (gap 11's rule: **a URL flipped before its bundle is installed turns that host red by construction**): install agent 1.1.0 + bundle → restart the unit → the `--status` CA-only https probe answers → **then** the `HOST_TARGETS` line flips → the dev server restarts → `agent.tls` shows. 1.1.0 ships **together with** the bundle, never the bundle alone (the installed agents are 1.0.0 — gap 11's re-check). Hosts in order: gateway (the trivial one: `(ALL) NOPASSWD: ALL`, a VM with the standard unit), deskwork (no `ops` installer grant — its root half is the operator's action), driveripper (the gap-10 line, the one that must not be the Oct-18 snapshot).
- ASSUMPTION: the executor has **ssh as `ops`** from the dev box (the fleet record's probes ran from this laptop; the repo's dev workflow dials the fleet from here). If ssh is unavailable in the run environment, the task degrades to its written form: the exact command blocks are printed for the operator, the operator applies them, and task 04's verification runs against the applied fleet — the pass criteria are the same either way.
- ASSUMPTION: the flipped URL is **clean `https://<host>:9120` with no userinfo** — the TLS handshake is the auth, the token file line stays in the unit (the rollback path), and invariant 6's "no token in a tracked file" is satisfied by `.env.dev` itself being gitignored with the token now out of the URL. **Rollback** per host = restore the pre-flip URL line from the timestamped backup copy (made in `data/env-backup/` before any flip) and restart the dev server — the bundle stays installed, the unit keeps both its token file and the bundle line.
## Work
1. **Prep (dev box):** `./scripts/dev-server.sh restart` (task 01's export leaves `data/certs/` populated); sign in, note the session token (it lives only in the operator's shell and the task transcript's redaction, never in a file); `cp .env.dev data/env-backup/.env.dev-<timestamp>` (gitignored).
2. **gateway** (`10.1.1.27:9120`, the standard unit): over ssh as `ops` — copy the repo's `agent/host_agent.py` (1.1.0) to `/usr/local/lib/dashboards-agent/host_agent.py`, then `sudo deploy/install-agent.sh --bind 10.1.1.27 --port 9120 --fetch-bundle http://127.0.0.1:8090 --cert-name gateway --session <token>` (the fetch dials the **dev server on the dev box** — the dashboard's bundle route; on a host that cannot reach the dev box, the same flag with the dashboard's LAN URL, or task 01's exported file, is the fallback; the unit gains the `--cert-bundle` + `RuntimeDirectory` lines and keeps its `--token-file` line), `systemctl restart dashboards-agent`, `./deploy/install-agent.sh --status` (the CA-only https probe answers). **Then** flip gateway's `.env.dev` line to `https://10.1.1.27:9120`, `./scripts/dev-server.sh restart`, confirm `host:gateway`/`disks:gateway` `ok` with `agent.tls: true`.
3. **deskwork** (`10.1.0.51:9120`, the operator's root half): `ops` has no installer grant on deskwork (the fleet record), so the executor prints the exact block — the agent-file copy, `sudo deploy/install-agent.sh --bind 10.1.0.51 --port 9120 --fetch-bundle <dashboard url> --cert-name deskwork --session <token>`, the restart, the `--status` probe — for the operator to run as root; the executor then verifies the unit state over ssh (`systemctl is-active`, `systemctl cat`), flips the URL line and confirms `host:deskwork`/`disks:deskwork` `ok` with `agent.tls: true` (if the operator's run and the flip land before the executor's verify, the verify is the check; the command block is the record).
4. **driveripper** (`10.1.0.126:9120`, the gap-10 line): over ssh as `ops` — copy the repo's `agent/host_agent.py` (1.1.0) to `/usr/local/lib/dashboards-agent/host_agent.py`, then the **repo's** script with the gap-10 line exactly: `sudo deploy/install-agent.sh --bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force --fetch-bundle <dashboard url> --cert-name driveripper --session <token>` (the `--force` is what lands the unit change — task 17's guard refuses a differing re-run without it, and the line reproduces the `User=root` + `CapabilityBoundingSet` unit that keeps all 16 SMART reads live; **never** the Oct-18 sudo-granted snapshot — it predates the flags and the guard), `systemctl restart dashboards-agent`, the `--status` probe, **then** flip the URL line and confirm `host:driveripper`/`disks:driveripper` `ok` with `agent.tls: true` and the 16 drives still reading (a `smart: null` row here is the `--force` rewrite having changed the unit — stop and diff the unit against the fleet record's shape before anything else).
5. **Fleet state:** after all three flips, `.env.dev`'s `HOST_TARGETS` has no `http://` agent entry and no userinfo token; the three units still carry their `--token-file` lines (the rollback path); the transcript in `data/logs/` names hosts, ports and revs — no token, no PEM.
## Testing & Quality
- Coverage: n/a (fleet task; the pins are tasks 01–02's)
- The read-only rule applies to the **verification** half: no `upsd` command, no engine write, no `POST /api/ups/command`; the installer's own writes are the change this task exists to make
## Completion Criteria
- [ ] all three units run the 1.1.0 agent file from an installed bundle (`systemctl cat` + `--status` CA-only probe per host, transcript in `data/logs/`)
- [ ] `.env.dev`'s `HOST_TARGETS` is `https://` for all three hosts, no userinfo; the pre-flip backup exists in `data/env-backup/`
- [ ] `./scripts/dev-server.sh status` — nine sources `ok` with `agent.tls: true` on all three (the per-host flips never left the fleet red longer than the host's own restart — the per-host order is the proof)
- [ ] driveripper's 16 drives still read live on `/disks` after the `--force` rewrite
- [ ] the full suite + `bash .agents/validate.sh` green
@@ -0,0 +1,29 @@
# Task 04 — The fleet is mTLS: the end state, verified
**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "…so we never need non-mtls servers."
**Story:** n/a
## Objective
The end state is proven read-only, the way phase 15's task-05 final pass proved its composition: the dashboard dials all three agents with its client certificate, the issued-vs-deployed revisions agree, nothing non-mTLS is left in the config, and the things the bundle install must not have broken (driveripper's 16 SMART reads, deskwork's GPU row) still work. No live revocation is performed — the revocation wiring is phase 15's e2e proof (`data/logs/verify-15-tls-e2e.py`), and a live revoke of a working fleet is a change, not a check.
## Work
1. `./scripts/dev-server.sh status` — the nine-source table `ok` (`host:driveripper`, `host:deskwork`, `host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`, `llm:vllm`); `disks:gateway` is `ok` with the page hiding gateway (phase 22's gate) — the job healthy, the page scoped.
2. Signed-in `GET /api/certs`: all three rows `status: "active"`, `deployed_bundle_rev == bundle_rev` (the "install the new bundle" sentence is gone for every host), `agent` block from the `system` payload: `agent.tls: true` + `agent.bundle_rev` present on all three (the task-03 passthrough — a token-mode snapshot grows nothing, so the fields' **presence** is the mTLS proof, rule 2's shape).
3. Signed-in `GET /api/system/snapshot` + `GET /api/disks/snapshot`: the `agent` blocks on all three hosts; gateway's row absent from `/disks` (phase 22); driveripper's 16 drives with live SMART numbers (the `--force` unit rewrite is the gap-10 shape — a `smart: null` row here fails the task); deskwork's `nvme0n1` row and its GPU card on `/system` (the 1.1.0 agent file's reads intact).
4. The config: `grep` of `.env.dev` — no `http://` in `HOST_TARGETS`, no `@` userinfo in any target URL; `data/env-backup/` holds the pre-flip copy (the rollback path exists on disk).
5. The negative control, **without touching the fleet**: `data/logs/verify-15-tls-e2e.py`'s transcript (phase 15) is the standing proof that a revoked serial is refused on `/health` too; this task re-runs **none** of it live — it names the transcript as the control and checks only that `tests/test_collectors.py`'s revocation-before-dial pin and `tests/test_api.py`'s `build_hub` wiring pin are green (they run in the suite).
6. Screenshots (invariant 10) in `data/logs/`: `/certs` (the three active rows with matching revs), `/disks` (the two visible hosts, driveripper's 16 live rows), `/system` (deskwork's GPU card) — the "the dashboard says all three are mTLS" proof is a screenshot, not a curl.
## Testing & Quality
- Coverage: n/a (verification task)
- The read-only rule: no `POST` to the fleet, no `upsd` command, no revocation, no unit change — the task reads the fleet and screenshots it
## Completion Criteria
- [ ] nine sources `ok`; `deployed_bundle_rev == bundle_rev` and `agent.tls: true` for all three hosts (pinned by the API checks, shown by the screenshots)
- [ ] `.env.dev` has no `http://` agent URL and no userinfo; the pre-flip backup exists
- [ ] driveripper's 16 SMART reads and deskwork's GPU card are live in the served payloads
- [ ] screenshots of `/certs`, `/disks`, `/system` in `data/logs/`
- [ ] the revocation pins (`tests/test_collectors.py`, `tests/test_api.py`) green in the suite
- [ ] the full suite + `bash .agents/validate.sh` green; AGENTS.md's fleet record and gap 11 updated by the phase's Documentation & Consistency pass to the mTLS end state