carlos / platform Public

plans: remote host-agent (#53) + Pi enablement — task-by-task implementation plans

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1f6193f225d463aecd4c2a3f510dc9843b03b664 parent 6157877
2 files changed, +1506 −0
  • docs/superpowers/plans/2026-08-04-pi-enablement.md +247 −0
  • docs/superpowers/plans/2026-08-04-remote-host-agent.md +1259 −0
diff --git a/docs/superpowers/plans/2026-08-04-pi-enablement.md b/docs/superpowers/plans/2026-08-04-pi-enablement.md
new file mode 100644
index 000000000..ab51d1113
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-pi-enablement.md
@@ -0,0 +1,247 @@
+# Raspberry Pi Instance Host (dev platform) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Depends on the remote-host-agent plan (`2026-08-04-remote-host-agent.md`) being merged first** — everything here consumes its machinery.
+
+**Goal:** A Raspberry Pi 4 (4 GB) joins the dev platform (Kass account, 640012953448) as the instance host for region `pi`, serving a demo fleet of ≥5 resident + ≥50 parked instances through the dev edge over the reverse channel.
+
+**Architecture:** No new placement machinery — the Pi is a region with its own data bucket (the hq pattern from Plan C Task C6); its activator is the only one pointed at that bucket, so residency IS placement. No live fallback: Pi instances are Pi-resident, and a dark Pi renders unreachable. Spec: `docs/superpowers/specs/2026-08-04-remote-host-pi-design.md`.
+
+**Tech Stack:** OpenTofu (module in this repo, root in the private platform-infrastructure repo), bash + `carlos` CLI (v0.4.0+ console-API verbs), 64-bit Raspberry Pi OS Lite.
+
+## Global Constraints
+
+- The off-cloud IAM principal is scoped to exactly the deployment bucket + the `pi` region bucket — wrong-region-fails-at-IAM must hold (spec; C6 decision 6).
+- The module must be reusable as-is for tito's hq Step 1 (same shape, different account/names).
+- 64-bit OS is mandatory: hosts resolve artifacts by `runtime.GOOS + "-" + runtime.GOARCH` (`internal/release/adopt.go:22`); 32-bit Raspberry Pi OS resolves nothing.
+- Demo instances are created via the console-API path (`carlos apps create`, PR #52) — no hand-rolled signup.
+- No live fallback wiring for region `pi` (approved design decision — a Wi-Fi blip must never migrate the demo).
+- Tofu roots live in the private `carlosframework/platform-infrastructure` repo (per `infra/README.md`); this repo carries only the module + documentation of the root's instantiation.
+- Website-feature-sync rule applies at merge: "run CARLOS on a Raspberry Pi" is website material — tag/tap + website PR in the same batch.
+
+---
+
+### Task 1: Tofu module `infra/modules/region-remote-host`
+
+**Files:**
+- Create: `infra/modules/region-remote-host/main.tf`, `variables.tf`, `outputs.tf`, `README.md`
+
+**Interfaces:**
+- Consumes: nothing in-repo (standalone module; mirrors `infra/modules/region-host`'s conventions — read its `main.tf`/`variables.tf` first and match style, provider constraints, and tagging).
+- Produces: a region data bucket + an IAM user with a scoped inline policy + an access key, for a host with no instance profile. Outputs: `bucket_name`, `access_key_id`, `secret_access_key` (sensitive).
+
+- [ ] **Step 1: Write the module.** `variables.tf`:
+
+```hcl
+variable "region_code" {
+ description = "Short region code this host serves (e.g. pi, hq)."
+ type = string
+}
+
+variable "data_bucket_name" {
+ description = "Name of this region's data bucket (created here)."
+ type = string
+}
+
+variable "deployment_bucket_arn" {
+ description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to."
+ type = string
+}
+```
+
+`main.tf` (adapt provider/tag boilerplate from `infra/modules/region-host/main.tf` so the two modules read as siblings):
+
+```hcl
+resource "aws_s3_bucket" "data" {
+ bucket = var.data_bucket_name
+}
+
+resource "aws_s3_bucket_public_access_block" "data" {
+ bucket = aws_s3_bucket.data.id
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+}
+
+# The off-cloud host principal. No instance profile exists off EC2; this
+# user + access key is the C6 decision-6 shape (IAM Roles Anywhere is the
+# later hardening — the policy below is what either principal would carry).
+resource "aws_iam_user" "host" {
+ name = "carlos-remote-host-${var.region_code}"
+}
+
+resource "aws_iam_access_key" "host" {
+ user = aws_iam_user.host.name
+}
+
+# Scope: the deployment bucket + THIS region's data bucket and nothing
+# else — wrong-region-fails-at-IAM, same property as every cloud host.
+resource "aws_iam_user_policy" "host" {
+ name = "carlos-remote-host-${var.region_code}"
+ user = aws_iam_user.host.name
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Sid = "DeploymentBucket"
+ Effect = "Allow"
+ Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
+ Resource = [var.deployment_bucket_arn, "${var.deployment_bucket_arn}/*"]
+ },
+ {
+ Sid = "OwnRegionData"
+ Effect = "Allow"
+ Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
+ Resource = [aws_s3_bucket.data.arn, "${aws_s3_bucket.data.arn}/*"]
+ },
+ ]
+ })
+}
+```
+
+`outputs.tf`:
+
+```hcl
+output "bucket_name" { value = aws_s3_bucket.data.bucket }
+output "access_key_id" { value = aws_iam_access_key.host.id }
+output "secret_access_key" {
+ value = aws_iam_access_key.host.secret
+ sensitive = true
+}
+```
+
+`README.md`: one paragraph — what it is, the two consumers (dev `pi`, tito `hq` Step 1), and that S3 actions in the policy must be reconciled against what `region-host`'s policy grants (if region-host's policy names a different/narrower action set, MATCH IT — read `infra/modules/region-host/main.tf:60-116` while writing and copy its action list verbatim; the list above is a starting point, region-host is the authority).
+- [ ] **Step 2: Validate** — `tofu -chdir=infra/modules/region-remote-host init -backend=false && tofu -chdir=infra/modules/region-remote-host validate` → valid. (Sandbox may block the provider download; if `init` cannot fetch providers offline, record that validation ran only `tofu fmt -check` and flag the full validate for the operator.)
+- [ ] **Step 3: Commit** — `infra: region-remote-host module — data bucket + scoped off-cloud principal (pi now, hq later)`.
+
+---
+
+### Task 2: Demo seeding script
+
+**Files:**
+- Create: `scripts/seed-pi-demo.sh`
+
+**Interfaces:**
+- Consumes: `carlos apps create` (console-API verb, PR #52; rate limit is 10 apps/address/day — the script must respect it), `carlos route -tunnel` (remote-host-agent plan Task 1).
+- Produces: an idempotent script an operator runs from a dev-credentialed machine, in batches across days (55+ apps vs. the 10/day limiter), plus edge-side tunnel route backing.
+
+- [ ] **Step 1: Write the script:**
+
+```bash
+#!/usr/bin/env bash
+# Seed the Pi demo fleet on the dev platform: N resident + M parked demo
+# instances, all resident in region "pi" (the Pi's own data bucket — no
+# placement machinery, residency IS placement), edge routes backed by the
+# Pi's reverse channel.
+#
+# The console API's app-create limiter is 10/address/day, so run this in
+# daily batches: seed-pi-demo.sh <start-index> <count>. State is the dev
+# platform itself — re-running an already-created index is a no-op.
+set -euo pipefail
+
+START=${1:?usage: seed-pi-demo.sh <start-index> <count>}
+COUNT=${2:?usage: seed-pi-demo.sh <start-index> <count>}
+TUNNEL_NAME=${CARLOS_PI_TUNNEL_NAME:?set CARLOS_PI_TUNNEL_NAME (the Pi's channel name)}
+if (( COUNT > 10 )); then
+ echo "warning: console API limits app creation to 10/address/day" >&2
+fi
+
+for i in $(seq "$START" $((START + COUNT - 1))); do
+ app=$(printf 'pi-demo-%03d' "$i")
+ if carlos apps ls | grep -q "^${app}\b"; then
+ echo "exists: $app"
+ else
+ carlos apps create "$app"
+ echo "created: $app"
+ fi
+done
+
+echo
+echo "Next, on the DEV EDGE box, back each new hostname with the Pi channel:"
+echo " carlos route -host <hostname-of-app> -tunnel ${TUNNEL_NAME}"
+echo "and on the PI, add the matching local routes (carlos add / adopt as usual)."
+echo "First 5 apps stay resident (no hibernate flag); the rest get the"
+echo "hibernate flag on their Pi-local routes so they park."
+```
+
+ Before committing, verify the verb names against the CLI actually on main (`carlos apps -h` / cli.go): if the listing verb is `carlos apps list` rather than `ls`, or create takes flags (account, region), match reality — the PR #52 branch is the authority. Adjust the grep accordingly.
+- [ ] **Step 2:** `chmod +x scripts/seed-pi-demo.sh && bash -n scripts/seed-pi-demo.sh` → syntax-clean.
+- [ ] **Step 3: Commit** — `scripts: pi demo fleet seeding (batched under the 10/day app-create limit)`.
+
+---
+
+### Task 3: Region-pi runbook (concrete env, dev values)
+
+**Files:**
+- Create: `infra/dev/region-pi.md` (create the `infra/dev/` directory; it mirrors where the hq physical runbook will live for tito)
+
+**Interfaces:**
+- Consumes: `infra/remote-host.md` (the generic software half, remote-host-agent plan Task 8); this file is the dev-Pi-specific instantiation.
+
+- [ ] **Step 1: Write the runbook:**
+
+```markdown
+# Region pi — Raspberry Pi 4 host on the dev platform
+
+Hardware: Raspberry Pi 4, 4 GB. Storage: SSD over USB3 strongly preferred;
+high-endurance SD acceptable for the demo (SQLite + WAL streaming is the
+I/O load, and SD I/O is the honest constraint on this board).
+
+OS: 64-bit Raspberry Pi OS Lite — MANDATORY. Hosts pick release artifacts
+by GOOS-GOARCH; this Pi resolves linux-arm64, the same artifact the
+Graviton fleet runs. 32-bit resolves nothing.
+
+Follow infra/remote-host.md with these dev-platform values:
+
+- Tofu: instantiate infra/modules/region-remote-host in the dev root
+ (platform-infrastructure repo) as:
+ module "region_pi" {
+ source = "git::https://github.com/carlosframework/platform.git//infra/modules/region-remote-host"
+ region_code = "pi"
+ data_bucket_name = "carlos-dev-pi-640012953448"
+ deployment_bucket_arn = <dev deployment bucket ARN>
+ }
+ Apply from the platform-infrastructure checkout (operator step — the
+ root module is not in this repo).
+- /etc/carlos/credentials: the module's access key output (0600).
+- /etc/carlos/host.env additions beyond the standard agent env:
+ AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials
+ CARLOS_HIBERNATE_BUCKET=carlos-dev-pi-640012953448
+ STATUS_BOX_REGION="Pi · demo"
+ CARLOS_TUNNEL_URL=https://<dev tunnel hostname>
+ CARLOS_TUNNEL_TOKEN=<from the edge's CARLOS_TUNNEL_CLIENTS entry>
+ CARLOS_TUNNEL_NAME=pi-box
+- Dev edge host.env additions:
+ CARLOS_TUNNEL_HOST=<dev tunnel hostname, DNS → dev edge>
+ CARLOS_TUNNEL_CLIENTS=pi-box=<token>
+ (Restart the dev edge to pick these up. Mint the token with
+ `openssl rand -hex 32`; it lives only in these two env files.)
+- No live fallback: no other host gets IAM on the pi bucket. A dark Pi
+ shows UNREACHABLE in the fleet view and `carlos status -live`; instances
+ resume when it re-dials. This is the approved demo posture.
+
+Seeding: scripts/seed-pi-demo.sh (batches of ≤10/day — console limiter).
+Target: ≥5 resident + ≥50 parked.
+
+## Demo acceptance (run these, tick them)
+
+- [ ] Pi appears in `carlos status -live` with metrics + tunnel=connected.
+- [ ] A resident instance serves through the dev edge over the channel.
+- [ ] A parked instance wakes on first request through the channel.
+- [ ] Pull Wi-Fi, restore it: next request succeeds after re-dial
+ (seconds — the bound the e2e test asserts in-repo).
+- [ ] Power off: fleet shows UNREACHABLE (age, no stale numbers).
+ Power on: serving resumes; `carlos status -live` confirms nothing
+ migrated (instances still pi-resident).
+```
+
+- [ ] **Step 2: Commit** — `infra: region-pi runbook — Pi 4 as dev-platform instance host`.
+
+---
+
+### Task 4: Final verification + PR + website batch
+
+- [ ] **Step 1:** `go build ./... && go vet ./... && go test ./... -count=1` (unchanged code, but the gate is unconditional) + `tofu fmt -check infra/modules/region-remote-host` → clean.
+- [ ] **Step 2:** Open the PR: "Raspberry Pi instance host: region pi on the dev platform". Body links the spec, the remote-host-agent PR it stacks on, and lists the operator steps that remain manual (tofu apply in platform-infrastructure, token mint, Pi hands-on, seeding batches). Draft until the remote-host-agent PR merges.
+- [ ] **Step 3:** At merge: website-feature-sync batch — the "CARLOS on a Raspberry Pi" story (tag/tap + website PR together; apex deploy stays manual).
+- [ ] **Step 4:** After the live demo acceptance boxes in `infra/dev/region-pi.md` are ticked by the operator, record actual measured numbers (resident count achieved, wake latency, reconnect time) back into that file — measured, not asserted.
diff --git a/docs/superpowers/plans/2026-08-04-remote-host-agent.md b/docs/superpowers/plans/2026-08-04-remote-host-agent.md
new file mode 100644
index 000000000..fe9befe08
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-remote-host-agent.md
@@ -0,0 +1,1259 @@
+# Remote Host-Agent (issue #53, C6 Step 3) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** The existing `carlos` binary gains a remote host-agent role: tunnel-backed routes on the edge (reverse HTTP/2 over a host-dialed connection), raw host metrics in the heartbeat Box snapshot (banded before anything public), verified dark-then-returns lease semantics, and an off-cloud bootstrap path.
+
+**Architecture:** The host dials the edge's existing 443 listener on a reserved hostname, the connection inverts (edge = h2 client, host = h2 server), and tunnel-backed routes proxy over it via the same `httputil.ReverseProxy` discipline as addr-backed routes. Spec: `docs/superpowers/specs/2026-08-04-remote-host-pi-design.md`. Charter: issue #53; settled architecture is NOT relitigated (outbound-only, no new binary).
+
+**Tech Stack:** Go 1.26, `golang.org/x/net/http2` (already in module graph as indirect — promoting to direct needs no network), stdlib `httputil`, existing S3/lease/status machinery.
+
+## Global Constraints
+
+- Wire-shape changes are ADDITIVE: Box snapshots, registry rows, and route JSON written before this change must still parse (repo rule, learned three times).
+- Consumer list for any shape change = everything that PARSES the shape, not everything that calls the API.
+- All 14 packages: `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l` clean on touched files, before any commit lands.
+- Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; disable the tool sandbox if the toolchain reports "read-only file system".
+- Production channel names pinned in `internal/release/laddercheck_test.go` must keep passing.
+- A tunnel-backed host is exactly as untrusted as an addr-backed one: `SetXForwarded` + `scrubInboundPurgeHeader` inbound, `applyPurgeHeader`/`purgeModifier` outbound, unchanged.
+- Raw metrics NEVER reach the publicly served `status/fleet.json` — the privacy banding in `CollectBox`/`BuildFleet` is a public commitment.
+- Per CLAUDE.md delegation policy: Opus implements/reviews the edge listener, auth, and lease/failover tasks (1, 2, 3, 4, 9, 10); Sonnet fits tasks 5–8; the reviewer for the security-adjacent tasks is Opus.
+- Work happens on the existing worktree branch `worktree-remote-host-agent`; draft PR when Task 1 lands.
+
+**Recorded divergences from issue #53 wording** (carry into the PR description and the C6 Step 3 tick):
+1. "current release + channel" per box: the Box heartbeat carries the running binary's release (`version.Build`). There is no box-level platform channel (channels are per-route; verified by grep — no `CARLOS_PLATFORM_CHANNEL` exists), so no channel field is invented.
+2. Resident/parked instance counts already exist on the Box (`RouteCounts.Resident`/`Hibernating`) — not duplicated into the new metrics block.
+3. Transport is reverse HTTP/2, not a websocket port of titogo's tunnel (the in-repo judgment call the issue delegates; rationale in the spec).
+
+---
+
+### Task 1: Registry `Tunnel` column + `carlos route -tunnel` setter
+
+**Files:**
+- Modify: `internal/registry/registry.go` (Route struct ~line 30–103, migrate ALTER list ~line 163, scan ~line 353, Upsert ~line 460, narrow setters near `SetEnvironment`)
+- Modify: `cli.go` (runRoute — follow the existing `-environment` flag shape)
+- Test: `internal/registry/registry_test.go`
+
+**Interfaces:**
+- Consumes: existing `Route`, `Registry.Upsert`, `SetEnvironment` precedent.
+- Produces: `Route.Tunnel string` (channel name backing this route; `""` = not tunnel-backed), `(*Registry).SetTunnel(host, tunnel string) error`, CLI `carlos route -host <h> -tunnel <name|->`. Upsert PRESERVES a non-empty existing Tunnel (same rationale as Environment: full-row re-Upserts must not silently unbind).
+
+- [ ] **Step 1: Write the failing tests** (model on the existing Environment preservation tests in `registry_test.go` — read them first, mirror their harness):
+
+```go
+func TestTunnelColumnRoundTrips(t *testing.T) {
+ r := testRegistry(t) // whatever helper the file already uses to open a temp registry
+ must(t, r.Upsert(registry.Route{Host: "demo.example", Kind: "instance", Status: "ready"}))
+ must(t, r.SetTunnel("demo.example", "pi-box"))
+ rt := mustGet(t, r, "demo.example")
+ if rt.Tunnel != "pi-box" {
+ t.Fatalf("Tunnel = %q, want pi-box", rt.Tunnel)
+ }
+}
+
+func TestUpsertPreservesTunnel(t *testing.T) {
+ r := testRegistry(t)
+ must(t, r.Upsert(registry.Route{Host: "demo.example", Kind: "instance", Status: "ready"}))
+ must(t, r.SetTunnel("demo.example", "pi-box"))
+ // A full-row re-Upsert with zero-value Tunnel must not unbind.
+ must(t, r.Upsert(registry.Route{Host: "demo.example", Kind: "instance", Status: "ready"}))
+ if rt := mustGet(t, r, "demo.example"); rt.Tunnel != "pi-box" {
+ t.Fatalf("Upsert clobbered Tunnel: %q", rt.Tunnel)
+ }
+ // Clearing has exactly one door: SetTunnel(host, "").
+ must(t, r.SetTunnel("demo.example", ""))
+ if rt := mustGet(t, r, "demo.example"); rt.Tunnel != "" {
+ t.Fatalf("SetTunnel clear failed: %q", rt.Tunnel)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/registry/ -run Tunnel -count=1` → FAIL (`rt.Tunnel undefined`).
+- [ ] **Step 3: Implement.** Add `Tunnel string` to `Route` (doc comment: "name of the reverse channel backing this route at the edge; written only by SetTunnel / carlos route -tunnel; Upsert preserves non-empty values — see Environment's comment for why"). Add `` `ALTER TABLE routes ADD COLUMN tunnel TEXT NOT NULL DEFAULT ''` `` to the migrate ALTER list. Add `tunnel` to the SELECT column list + Scan targets + Upsert (INSERT column, and the preserve-non-empty UPDATE clause exactly as Environment does — copy its SQL shape). Add `SetTunnel` modeled byte-for-byte on `SetEnvironment`.
+- [ ] **Step 4: Run tests** — `go test ./internal/registry/ -count=1` → PASS (all, not just new).
+- [ ] **Step 5: CLI flag.** In `cli.go` runRoute, add `-tunnel` string flag following `-environment`'s pattern exactly (including `-` meaning clear and the platform/canary-owned-row refusal if `-environment` has one — mirror it). Manual check: `go run . route -h` shows the flag.
+- [ ] **Step 6: Full gate + commit** — `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/registry/registry.go cli.go` (expect no output from gofmt). Commit: `git add -A && git commit -m "registry: additive Tunnel column, preserved on Upsert, set via carlos route -tunnel"`.
+
+---
+
+### Task 2: Edge `TunnelHub` — authenticated reverse-channel registration
+
+**Files:**
+- Create: `internal/edge/tunnel.go`
+- Test: `internal/edge/tunnel_test.go`
+- Modify: `go.mod` (promote `golang.org/x/net` to direct require — `-mod=mod` does this automatically on build; no network needed, v0.55.0 is in the module cache)
+
+**Interfaces:**
+- Produces (consumed by Tasks 3, 4, 10):
+ - `type TunnelHub struct{...}` with `NewTunnelHub(host string, clients map[string]string) *TunnelHub` (host = reserved hostname; clients = channel name → bearer token)
+ - `(*TunnelHub).ServeHTTP(w, r)` — the registration endpoint (only ever invoked for the reserved hostname)
+ - `(*TunnelHub).transportFor(name string) http.RoundTripper` — errors when the channel is down (ReverseProxy turns that into 502)
+ - `(*TunnelHub).State(name string) string` — `"connected"` or `""`
+ - `(*TunnelHub).Host() string`
+ - Wire protocol: `GET /register`, headers `Authorization: Bearer <token>`, `Carlos-Tunnel-Name: <name>`, `Connection: Upgrade`, `Upgrade: carlos-tunnel/1` → `101 Switching Protocols` → connection inverts (edge h2 client / host h2 server).
+
+- [ ] **Step 1: Write the failing tests:**
+
+```go
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestTunnelRegisterRejectsBadToken(t *testing.T) {
+ hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
+ for name, hdr := range map[string]http.Header{
+ "no auth": {},
+ "wrong token": {"Authorization": {"Bearer nope"}, "Carlos-Tunnel-Name": {"pi-box"}},
+ "unknown name": {"Authorization": {"Bearer sekrit"}, "Carlos-Tunnel-Name": {"other"}},
+ // A valid token may only claim ITS OWN channel name.
+ "cross-name": {"Authorization": {"Bearer sekrit"}, "Carlos-Tunnel-Name": {""}},
+ } {
+ req := httptest.NewRequest("GET", "/register", nil)
+ req.Header = hdr
+ rec := httptest.NewRecorder()
+ hub.ServeHTTP(rec, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("%s: got %d, want 401", name, rec.Code)
+ }
+ }
+}
+
+func TestTunnelRegisterRejectsUnknownPath(t *testing.T) {
+ hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
+ req := httptest.NewRequest("GET", "/anything-else", nil)
+ rec := httptest.NewRecorder()
+ hub.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("got %d, want 404", rec.Code)
+ }
+}
+
+func TestParseTunnelClients(t *testing.T) {
+ got, err := parseTunnelClients("pi-box=sekrit,hq=other")
+ if err != nil || got["pi-box"] != "sekrit" || got["hq"] != "other" {
+ t.Fatalf("parse: %v %v", got, err)
+ }
+ if _, err := parseTunnelClients("malformed"); err == nil {
+ t.Fatal("want error on entry without '='")
+ }
+}
+```
+
+(The happy-path 101 + round-trip is Task 10's e2e — it needs a real hijackable conn, which httptest.NewRecorder can't provide.)
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/edge/ -run Tunnel -count=1` → FAIL (undefined: NewTunnelHub).
+- [ ] **Step 3: Implement `internal/edge/tunnel.go`:**
+
+```go
+// Package-internal reverse-channel hub: remote hosts dial OUT to the edge's
+// existing 443 listener on a reserved hostname, authenticate with a bearer
+// token bound to their channel name, and the connection inverts — the edge
+// becomes an HTTP/2 client over the hijacked conn, the host an HTTP/2 server.
+// Nothing ever connects TO a remote host (issue #53's locked-down property);
+// this file is the edge half of that bargain. Transport judgment call
+// (spec 2026-08-04): reverse h2 rather than a websocket port of titogo's
+// tunnel — x/net/http2 already supplies mux, flow control, and pings.
+package edge
+
+import (
+ "crypto/subtle"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "golang.org/x/net/http2"
+)
+
+const (
+ tunnelRegisterPath = "/register"
+ tunnelUpgradeProto = "carlos-tunnel/1"
+ // tunnelPingEvery / tunnelPingTimeout bound how long a half-dead channel
+ // can look connected: a failed or timed-out ping tears it down, the host
+ // notices its conn die and re-dials (seconds — the bound Task 10 asserts).
+ tunnelPingEvery = 15 * time.Second
+ tunnelPingTimeout = 10 * time.Second
+)
+
+type tunnelConn struct {
+ cc *http2.ClientConn
+ raw net.Conn
+}
+
+type TunnelHub struct {
+ host string // reserved hostname this hub answers on
+ clients map[string]string // channel name -> bearer token
+ mu sync.Mutex
+ conns map[string]*tunnelConn
+}
+
+func NewTunnelHub(host string, clients map[string]string) *TunnelHub {
+ return &TunnelHub{host: host, clients: clients, conns: make(map[string]*tunnelConn)}
+}
+
+func (h *TunnelHub) Host() string { return h.host }
+
+// parseTunnelClients parses CARLOS_TUNNEL_CLIENTS ("name=token[,name=token]").
+func parseTunnelClients(s string) (map[string]string, error) {
+ out := make(map[string]string)
+ for _, pair := range strings.Split(s, ",") {
+ pair = strings.TrimSpace(pair)
+ if pair == "" {
+ continue
+ }
+ name, token, ok := strings.Cut(pair, "=")
+ if !ok || name == "" || token == "" {
+ return nil, fmt.Errorf("tunnel clients: malformed entry %q (want name=token)", pair)
+ }
+ out[name] = token
+ }
+ return out, nil
+}
+
+// ServeHTTP handles ONLY the registration handshake; the Edge routes requests
+// here when r.Host equals the reserved hostname, so ordinary app traffic can
+// never reach it and this handler never sees ordinary traffic.
+func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != tunnelRegisterPath {
+ http.NotFound(w, r)
+ return
+ }
+ name := r.Header.Get("Carlos-Tunnel-Name")
+ token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
+ want, ok := h.clients[name]
+ // Constant-time compare; the name lookup happening first is fine — the
+ // name is not the secret, the token is. A token is bound to exactly one
+ // channel name, so a leaked token cannot impersonate a different host.
+ if name == "" || !ok || subtle.ConstantTimeCompare([]byte(token), []byte(want)) != 1 {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ hj, ok := w.(http.Hijacker)
+ if !ok {
+ http.Error(w, "hijack unsupported", http.StatusInternalServerError)
+ return
+ }
+ conn, bufrw, err := hj.Hijack()
+ if err != nil {
+ log.Printf("edge: tunnel %s: hijack: %v", name, err)
+ return
+ }
+ // Manual 101 on the hijacked conn (the ResponseWriter is spent once hijacked).
+ fmt.Fprintf(bufrw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: %s\r\nConnection: Upgrade\r\n\r\n", tunnelUpgradeProto)
+ if err := bufrw.Flush(); err != nil {
+ conn.Close()
+ return
+ }
+ tr := &http2.Transport{}
+ cc, err := tr.NewClientConn(conn)
+ if err != nil {
+ log.Printf("edge: tunnel %s: h2 client conn: %v", name, err)
+ conn.Close()
+ return
+ }
+ h.mu.Lock()
+ if old := h.conns[name]; old != nil {
+ old.raw.Close() // a re-register replaces the previous channel
+ }
+ h.conns[name] = &tunnelConn{cc: cc, raw: conn}
+ h.mu.Unlock()
+ log.Printf("edge: tunnel %s: connected from %s", name, conn.RemoteAddr())
+ go h.pingLoop(name, cc, conn)
+}
+
+// pingLoop is the edge-side liveness check: a dead ping closes the raw conn
+// and unregisters the channel, so requests fail fast to 502 instead of
+// hanging on a black-holed TCP session (the venue-Wi-Fi failure mode).
+func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn) {
+ for {
+ time.Sleep(tunnelPingEvery)
+ ctx, cancel := context.WithTimeout(context.Background(), tunnelPingTimeout)
+ err := cc.Ping(ctx)
+ cancel()
+ if err != nil {
+ log.Printf("edge: tunnel %s: ping failed (%v) — dropping channel", name, err)
+ raw.Close()
+ h.mu.Lock()
+ if cur := h.conns[name]; cur != nil && cur.raw == raw {
+ delete(h.conns, name)
+ }
+ h.mu.Unlock()
+ return
+ }
+ }
+}
+
+func (h *TunnelHub) conn(name string) *http2.ClientConn {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if c := h.conns[name]; c != nil {
+ return c.cc
+ }
+ return nil
+}
+
+// State reports "connected" or "" — consumed by fleet status rendering.
+func (h *TunnelHub) State(name string) string {
+ if h.conn(name) != nil {
+ return "connected"
+ }
+ return ""
+}
+
+type roundTripperFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+
+// transportFor resolves the channel PER REQUEST, so a proxy built before a
+// reconnect uses the fresh channel afterwards with no reload needed.
+func (h *TunnelHub) transportFor(name string) http.RoundTripper {
+ return roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ cc := h.conn(name)
+ if cc == nil {
+ return nil, fmt.Errorf("tunnel %q not connected", name)
+ }
+ return cc.RoundTrip(req)
+ })
+}
+```
+
+(Add `"context"` to imports. `go build ./internal/edge/` promotes `golang.org/x/net` to a direct require in go.mod under `-mod=mod` — commit the go.mod/go.sum change with this task.)
+
+- [ ] **Step 4: Run tests** — `go test ./internal/edge/ -run Tunnel -count=1` → PASS.
+- [ ] **Step 5: Full gate + commit** — all four commands clean. Commit: `edge: TunnelHub — authenticated reverse-channel registration (reverse h2 over hijacked 443)`.
+
+---
+
+### Task 3: Edge dispatch — tunnel-backed routes + hub wiring
+
+**Files:**
+- Modify: `internal/edge/edge.go` (Edge struct ~line 77, `Allowed` ~line 282, `ServeHTTP` ~line 287, `Reload` switch ~line 234–248, proxy constructors ~line 760–803, and `RunWithActivator` for env wiring)
+- Test: `internal/edge/tunnel_test.go` (extend)
+
+**Interfaces:**
+- Consumes: `TunnelHub` (Task 2), `Route.Tunnel` (Task 1), `purgeModifier`, `scrubInboundPurgeHeader`.
+- Produces: `(*Edge).EnableTunnels(hub *TunnelHub)`; env contract `CARLOS_TUNNEL_HOST` (reserved hostname) + `CARLOS_TUNNEL_CLIENTS` (name=token list) parsed in `RunWithActivator`; ready-hook seam `RunWithActivatorReady(args, act, cache, ready func(*Edge))` (consumed by Task 4).
+
+- [ ] **Step 1: Write the failing test** (extend tunnel_test.go):
+
+```go
+// testRegistryWithRoute opens a temp registry and upserts one route. If
+// edge_test.go already has an equivalent helper, use that instead — one
+// harness, not two.
+func testRegistryWithRoute(t *testing.T, rt registry.Route) *registry.Registry {
+ t.Helper()
+ r, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { r.Close() })
+ if err := r.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ return r
+}
+
+func TestReloadBuildsTunnelBackedRoute(t *testing.T) {
+ reg := testRegistryWithRoute(t, registry.Route{
+ Host: "demo.example", Kind: "instance", Status: "ready", Tunnel: "pi-box",
+ })
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
+ e.EnableTunnels(hub)
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ // Without EnableTunnels the route must be skipped (default: continue);
+ // with it, the host must be served and Allowed, and the reserved
+ // hostname must be Allowed for cert minting.
+ if !e.Allowed("demo.example") {
+ t.Fatal("tunnel-backed route not served after EnableTunnels+Reload")
+ }
+ if !e.Allowed("tunnel.test") {
+ t.Fatal("reserved tunnel hostname must be Allowed (autocert HostPolicy)")
+ }
+ // Channel down -> 502, not a hang.
+ req := httptest.NewRequest("GET", "http://demo.example/", nil)
+ req.Host = "demo.example"
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadGateway {
+ t.Fatalf("disconnected tunnel: got %d, want 502", rec.Code)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/edge/ -run TestReloadBuildsTunnelBackedRoute -count=1` → FAIL (undefined: EnableTunnels).
+- [ ] **Step 3: Implement.**
+ - Edge struct: add `tunnels *TunnelHub` field with doc comment ("nil = tunnel serving not configured; routes with Tunnel set are skipped, same nil-means-disabled convention as act/cache").
+ - `func (e *Edge) EnableTunnels(hub *TunnelHub) { e.tunnels = hub }` — call before Reload, same contract as EnableStatic.
+ - `Allowed`: `if e.tunnels != nil && host == e.tunnels.Host() { return true }` before the map lookup.
+ - `ServeHTTP`: immediately after the host split (~line 291), before the upstream lookup: `if e.tunnels != nil && host == e.tunnels.Host() { e.tunnels.ServeHTTP(w, r); return }`.
+ - `Reload` switch — new case after `t.Addr != ""` (a tunnel route has no socket/addr, so order vs. those is moot, but keep it before the static/status kinds so an instance-kind tunnel route can't fall through):
+
+```go
+case t.Tunnel != "":
+ if e.tunnels == nil {
+ continue // tunnel serving not configured on this box
+ }
+ p := tunnelProxy(t.Tunnel, e.tunnels)
+ p.ModifyResponse = e.purgeModifier(t)
+ proxy = p
+```
+
+ - Proxy constructor beside tcpProxy:
+
+```go
+// tunnelProxy reverse-proxies over a named reverse channel (TunnelHub).
+// Same Host preservation and purge-header discipline as tcpProxy — a
+// tunnel-backed host is exactly as untrusted as an addr-backed one. The
+// scheme is https because the h2 ClientConn requires it; no dial happens
+// (the channel already exists), so the value is otherwise inert.
+func tunnelProxy(name string, hub *TunnelHub) *httputil.ReverseProxy {
+ return &httputil.ReverseProxy{
+ Rewrite: func(pr *httputil.ProxyRequest) {
+ pr.SetXForwarded()
+ pr.Out.URL.Scheme = "https"
+ pr.Out.URL.Host = pr.In.Host // h2 :authority = the real hostname
+ pr.Out.Host = pr.In.Host
+ scrubInboundPurgeHeader(pr)
+ },
+ Transport: hub.transportFor(name),
+ }
+}
+```
+
+ - `RunWithActivator` wiring: rename the body to `RunWithActivatorReady(args []string, act Activator, cache *Cache, ready func(*Edge))`; keep `RunWithActivator` as a wrapper passing `nil`. Inside, after the Edge is fully configured (after EnableCache/EnableStatic and before the blocking serve/listen), add:
+
+```go
+if th := os.Getenv("CARLOS_TUNNEL_HOST"); th != "" {
+ clients, err := parseTunnelClients(os.Getenv("CARLOS_TUNNEL_CLIENTS"))
+ if err != nil {
+ log.Fatalf("edge: %v", err)
+ }
+ if len(clients) == 0 {
+ log.Fatalf("edge: CARLOS_TUNNEL_HOST set but CARLOS_TUNNEL_CLIENTS empty")
+ }
+ e.EnableTunnels(NewTunnelHub(th, clients))
+ if err := e.Reload(); err != nil {
+ log.Fatalf("edge: reload after EnableTunnels: %v", err)
+ }
+ log.Printf("edge: tunnel hub armed on %s (%d client(s))", th, len(clients))
+}
+if ready != nil {
+ ready(e)
+}
+```
+
+- [ ] **Step 4: Run tests** — `go test ./internal/edge/ -count=1` → PASS (whole package — the Reload/ServeHTTP changes must not break socket/addr/static/status tests).
+- [ ] **Step 5: Full gate + commit** — `edge: tunnel-backed route dispatch + hub wiring on the existing 443 listener`.
+
+---
+
+### Task 4: Host dial-out client (`tunnelLoop`)
+
+**Files:**
+- Create: `internal/host/tunnel_client.go`
+- Test: `internal/host/tunnel_client_test.go`
+- Modify: `internal/host/agent.go` (RunAgent ~line 146–157)
+
+**Interfaces:**
+- Consumes: Task 3's wire protocol and `RunWithActivatorReady` seam.
+- Produces: `tunnelLoop(handler http.Handler)` goroutine; `tunnelState() string` (`""` disabled / `"connected"` / `"reconnecting"`) consumed by Task 6's heartbeat probe; env contract `CARLOS_TUNNEL_URL`, `CARLOS_TUNNEL_TOKEN`, `CARLOS_TUNNEL_NAME`.
+
+- [ ] **Step 1: Write the failing test** — dial + serve against a hand-rolled upgrade server (no Edge involvement; the full loop is Task 10):
+
+```go
+package host
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "golang.org/x/net/http2"
+)
+
+// fakeTunnelEdge accepts one registration, answers 101, then drives ONE h2
+// request over the inverted conn and returns the response body it read.
+func fakeTunnelEdge(t *testing.T, wantToken, wantName string) (*httptest.Server, chan string) {
+ t.Helper()
+ got := make(chan string, 1)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer "+wantToken ||
+ r.Header.Get("Carlos-Tunnel-Name") != wantName {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ conn, bufrw, err := w.(http.Hijacker).Hijack()
+ if err != nil {
+ t.Errorf("hijack: %v", err)
+ return
+ }
+ fmt.Fprintf(bufrw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: carlos-tunnel/1\r\nConnection: Upgrade\r\n\r\n")
+ bufrw.Flush()
+ cc, err := (&http2.Transport{}).NewClientConn(conn)
+ if err != nil {
+ t.Errorf("h2 client: %v", err)
+ return
+ }
+ req, _ := http.NewRequest("GET", "https://demo.example/hello", nil)
+ resp, err := cc.RoundTrip(req)
+ if err != nil {
+ t.Errorf("roundtrip over tunnel: %v", err)
+ got <- ""
+ return
+ }
+ body, _ := io.ReadAll(resp.Body)
+ got <- resp.Header.Get("X-From-Host") + ":" + string(body)
+ }))
+ return srv, got
+}
+
+func TestDialAndServeInvertsConnection(t *testing.T) {
+ srv, got := fakeTunnelEdge(t, "sekrit", "pi-box")
+ defer srv.Close()
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-From-Host", r.Host)
+ fmt.Fprint(w, "pong")
+ })
+ conn, err := dialTunnel(srv.URL, "pi-box", "sekrit")
+ if err != nil {
+ t.Fatalf("dialTunnel: %v", err)
+ }
+ go serveTunnel(conn, handler)
+ if v := <-got; v != "demo.example:pong" {
+ t.Fatalf("over-tunnel response = %q, want demo.example:pong", v)
+ }
+}
+
+func TestDialTunnelRejectsNon101(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ }))
+ defer srv.Close()
+ if _, err := dialTunnel(srv.URL, "pi-box", "wrong"); err == nil {
+ t.Fatal("want error on 401 handshake")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/host/ -run Tunnel -count=1` → FAIL (undefined: dialTunnel).
+- [ ] **Step 3: Implement `internal/host/tunnel_client.go`:**
+
+```go
+// The host half of the reverse channel (issue #53 / C6 Step 3): dial OUT to
+// the edge, invert the connection, and serve our own local edge routing over
+// HTTP/2. Reconnect fast — hq's dual-WAN failover and a Pi's Wi-Fi blip are
+// both "the conn died, dial again", and that latency is user-visible.
+package host
+
+import (
+ "bufio"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "log"
+ "math/rand/v2"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "sync/atomic"
+ "time"
+
+ "golang.org/x/net/http2"
+)
+
+// tunnelStateVal is "" (disabled/never started), "connected", or
+// "reconnecting" — read by statusLoop's heartbeat probe (probes must fail
+// toward honest values; "" means the loop isn't running at all).
+var tunnelStateVal atomic.Value
+
+func tunnelState() string {
+ if v, ok := tunnelStateVal.Load().(string); ok {
+ return v
+ }
+ return ""
+}
+
+const (
+ tunnelBackoffFloor = 250 * time.Millisecond
+ tunnelBackoffCap = 2 * time.Second
+)
+
+// tunnelLoop dials CARLOS_TUNNEL_URL and serves handler over the inverted
+// conn, forever, with jittered capped backoff. Inert unless all three env
+// vars are set — the same convention as every other optional agent loop.
+func tunnelLoop(handler http.Handler) {
+ rawURL := os.Getenv("CARLOS_TUNNEL_URL")
+ token := os.Getenv("CARLOS_TUNNEL_TOKEN")
+ name := os.Getenv("CARLOS_TUNNEL_NAME")
+ if rawURL == "" || token == "" || name == "" {
+ if rawURL != "" || token != "" || name != "" {
+ log.Printf("agent: tunnel loop disabled: need all of CARLOS_TUNNEL_URL, CARLOS_TUNNEL_TOKEN, CARLOS_TUNNEL_NAME")
+ }
+ return
+ }
+ log.Printf("agent: tunnel loop armed (%s as %q)", rawURL, name)
+ backoff := tunnelBackoffFloor
+ for {
+ conn, err := dialTunnel(rawURL, name, token)
+ if err != nil {
+ tunnelStateVal.Store("reconnecting")
+ log.Printf("agent: tunnel dial: %v (retry in %s)", err, backoff)
+ time.Sleep(backoff + rand.N(backoff/2))
+ if backoff *= 2; backoff > tunnelBackoffCap {
+ backoff = tunnelBackoffCap
+ }
+ continue
+ }
+ backoff = tunnelBackoffFloor
+ tunnelStateVal.Store("connected")
+ log.Printf("agent: tunnel connected as %q", name)
+ serveTunnel(conn, handler) // blocks until the conn dies
+ tunnelStateVal.Store("reconnecting")
+ log.Printf("agent: tunnel lost — re-dialing")
+ }
+}
+
+// bufferedConn replays bytes the handshake's bufio.Reader over-read (the h2
+// preface can arrive in the same packet as the 101) before the raw conn.
+type bufferedConn struct {
+ r io.Reader
+ net.Conn
+}
+
+func (b bufferedConn) Read(p []byte) (int, error) { return b.r.Read(p) }
+
+// dialTunnel performs the registration handshake and returns the raw conn,
+// ready to serve h2 on. https URLs use real TLS (the edge's public cert —
+// server auth by PKI, client auth by bearer token); http is for tests.
+func dialTunnel(rawURL, name, token string) (net.Conn, error) {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return nil, fmt.Errorf("tunnel url: %w", err)
+ }
+ addr := u.Host
+ var conn net.Conn
+ switch u.Scheme {
+ case "https":
+ if u.Port() == "" {
+ addr = net.JoinHostPort(u.Hostname(), "443")
+ }
+ conn, err = tls.Dial("tcp", addr, &tls.Config{
+ ServerName: u.Hostname(),
+ NextProtos: []string{"http/1.1"}, // the handshake is 1.1; h2 starts after the 101
+ })
+ case "http":
+ conn, err = net.Dial("tcp", addr)
+ default:
+ return nil, fmt.Errorf("tunnel url: unsupported scheme %q", u.Scheme)
+ }
+ if err != nil {
+ return nil, err
+ }
+ req := &http.Request{
+ Method: "GET",
+ URL: &url.URL{Path: "/register"},
+ Host: u.Hostname(),
+ Header: http.Header{
+ "Authorization": {"Bearer " + token},
+ "Carlos-Tunnel-Name": {name},
+ "Connection": {"Upgrade"},
+ "Upgrade": {"carlos-tunnel/1"},
+ },
+ }
+ if err := req.Write(conn); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ br := bufio.NewReader(conn)
+ resp, err := http.ReadResponse(br, req)
+ if err != nil {
+ conn.Close()
+ return nil, err
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusSwitchingProtocols {
+ conn.Close()
+ return nil, fmt.Errorf("tunnel register: %s", resp.Status)
+ }
+ if br.Buffered() > 0 {
+ return bufferedConn{r: io.MultiReader(br, conn), Conn: conn}, nil
+ }
+ return conn, nil
+}
+
+// serveTunnel serves handler over the inverted conn until it dies.
+func serveTunnel(conn net.Conn, handler http.Handler) {
+ (&http2.Server{}).ServeConn(conn, &http2.ServeConnOpts{Handler: handler})
+}
+```
+
+- [ ] **Step 4: Run tests** — `go test ./internal/host/ -run Tunnel -count=1` → PASS.
+- [ ] **Step 5: Wire into RunAgent.** In `internal/host/agent.go` replace the final `edge.RunWithActivator(args, act, cache)` call with:
+
+```go
+edge.RunWithActivatorReady(args, act, cache, func(e *edge.Edge) {
+ go tunnelLoop(e)
+})
+```
+
+(`*edge.Edge` implements `http.Handler`; the tunnel serves the host's own local routing, so a request arriving over the channel takes exactly the path a local request would — activator wake included.)
+
+- [ ] **Step 6: Full gate + commit** — `host: tunnel dial-out loop — reverse channel client with capped jittered backoff`.
+
+---
+
+### Task 5: Status shape — additive `Metrics` block on Box + back-compat + dry-run leak scope
+
+**Files:**
+- Modify: `internal/status/snapshot.go` (package doc lines 1–7, `Probes` ~line 22, `Box` ~line 72, `CollectBox` ~line 184)
+- Modify: `internal/status/dryrun.go` (read it FIRST — it publishes a fixture fleet and asserts no fixture leaks into published objects)
+- Test: `internal/status/snapshot_test.go` (or the file the package's existing CollectBox tests live in — read and extend, don't create a parallel file)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces (consumed by Tasks 6, 7):
+
+```go
+// BoxMetrics is the raw, PRIVATE block: real numbers, never banded. It lives
+// in status/boxes/<label>.json — which only fleet-status machinery and
+// operators read — and is deliberately NOT copied into FleetBox, so the
+// publicly served fleet.json keeps the banded-words-only commitment.
+type BoxMetrics struct {
+ MemTotalBytes *int64 `json:"mem_total_bytes,omitempty"`
+ MemAvailBytes *int64 `json:"mem_avail_bytes,omitempty"`
+ Load1 *float64 `json:"load1,omitempty"`
+ DiskFreeBytes *int64 `json:"disk_free_bytes,omitempty"`
+ Release string `json:"release,omitempty"` // running binary's version.Build
+ Tunnel string `json:"tunnel,omitempty"` // ""|"connected"|"reconnecting"
+}
+```
+
+ plus `Box.Metrics *BoxMetrics` with `json:"metrics,omitempty"`, and `Probes` gains the same six fields (`MemTotalBytes`, `MemAvailBytes`, `Load1`, `DiskFreeBytes *int64/*float64`, `Release`, `Tunnel string`). Resident/parked counts are NOT duplicated — `RouteCounts` already carries them (recorded divergence 2).
+
+- [ ] **Step 1: Write the failing tests:**
+
+```go
+func TestCollectBoxCarriesRawMetrics(t *testing.T) {
+ mt, ma, df := int64(4056547328), int64(2028273664), int64(9999999)
+ l1 := 0.42
+ b := CollectBox(nil, Probes{
+ MemTotalBytes: &mt, MemAvailBytes: &ma, Load1: &l1, DiskFreeBytes: &df,
+ Release: "abc1234", Tunnel: "connected",
+ }, time.Now())
+ m := b.Metrics
+ if m == nil || *m.MemTotalBytes != mt || *m.Load1 != l1 || m.Release != "abc1234" || m.Tunnel != "connected" {
+ t.Fatalf("metrics block wrong: %+v", m)
+ }
+}
+
+// Yesterday's Box shape (no metrics key) must parse today, and a FUTURE
+// box's unknown keys must not break today's parse either.
+func TestBoxBackCompatParse(t *testing.T) {
+ yesterday := []byte(`{"generated_at":"2026-08-03T10:00:00Z","label":"ada-stargazing",
+ "routes":{"static":1,"resident":2,"hibernating":3,"awake":null,"asleep":null},
+ "load":"comfortable","capacity":"plenty",
+ "replication":{"healthy":true,"lag_seconds":5},
+ "adoption_running":true,"expired_canaries":0}`)
+ var b Box
+ if err := json.Unmarshal(yesterday, &b); err != nil {
+ t.Fatalf("yesterday's shape failed to parse: %v", err)
+ }
+ if b.Metrics != nil {
+ t.Fatal("absent metrics must decode as nil, not zero-value")
+ }
+ tomorrow := []byte(`{"generated_at":"2026-08-05T10:00:00Z","label":"ada-stargazing",
+ "routes":{"static":0,"resident":0,"hibernating":0,"awake":null,"asleep":null},
+ "load":null,"capacity":null,"replication":{"healthy":null,"lag_seconds":null},
+ "adoption_running":null,"expired_canaries":null,
+ "metrics":{"mem_total_bytes":1,"some_future_field":true}}`)
+ if err := json.Unmarshal(tomorrow, &b); err != nil {
+ t.Fatalf("tomorrow's shape failed to parse: %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/status/ -run 'CollectBoxCarriesRaw|BackCompat' -count=1` → FAIL.
+- [ ] **Step 3: Implement.** Add the fields; in `CollectBox`, after the existing Box literal is built, attach the block only when something is known (all-unknown publishes no block — additive for old parsers, quiet for boxes that can't answer):
+
+```go
+if p.MemTotalBytes != nil || p.MemAvailBytes != nil || p.Load1 != nil ||
+ p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" {
+ box.Metrics = &BoxMetrics{
+ MemTotalBytes: p.MemTotalBytes, MemAvailBytes: p.MemAvailBytes,
+ Load1: p.Load1, DiskFreeBytes: p.DiskFreeBytes,
+ Release: p.Release, Tunnel: p.Tunnel,
+ }
+}
+```
+
+ Update the package doc comment (lines 1–7) and Box's doc: banding is still enforced here for every PUBLIC field; the Metrics block is the deliberate raw/private exception, excluded from fleet.json by construction (BuildFleet copies fields explicitly into FleetBox and does not copy Metrics — verify by reading `fleet.go:116-124`, and say so in the comment).
+- [ ] **Step 4: Dry-run leak scope.** Read `internal/status/dryrun.go` end to end. Extend its fixture Probes with distinctive raw values (e.g. `MemTotalBytes = 4242424242`). Keep/extend its leak assertions so that: (a) `status/fleet.json` and `status/index.html` must NOT contain `4242424242` (raw never public); (b) the box object MAY contain it. If the existing leak check sweeps ALL published objects for fraction-like fixtures, scope that sweep to fleet.json + index.html for the new raw fixtures only — do not weaken the existing hostname/machine-id assertions for box objects.
+- [ ] **Step 5: Run tests** — `go test ./internal/status/ -count=1` → PASS (including DryRun tests).
+- [ ] **Step 6: Full gate + commit** — `status: additive raw Metrics block on Box — private to the bucket, never published to fleet.json`.
+
+---
+
+### Task 6: Host probes for the metrics block + heartbeat wiring
+
+**Files:**
+- Modify: `internal/host/probes.go`, `internal/host/agent.go` (statusLoop's Probes literal ~line 622)
+- Test: `internal/host/probes_test.go` (or wherever `parseIsActive`-style probe tests live — read first, extend in place)
+
+**Interfaces:**
+- Consumes: `Probes` fields (Task 5), `tunnelState()` (Task 4), `version.Build` (`internal/version`).
+- Produces: `readMemBytes() (total, avail *int64)`, `readLoad1() *float64`, `readDiskFreeBytes() *int64` — all fail toward nil, never a guessed zero (the file's contract).
+
+- [ ] **Step 1: Write the failing tests** — pure parsers, no /proc in tests (follow the file's design note):
+
+```go
+func TestParseMeminfoBytes(t *testing.T) {
+ total, avail := parseMeminfoBytes(strings.NewReader(
+ "MemTotal: 3961472 kB\nMemFree: 100 kB\nMemAvailable: 1980736 kB\n"))
+ if total == nil || *total != 3961472*1024 || avail == nil || *avail != 1980736*1024 {
+ t.Fatalf("got %v %v", total, avail)
+ }
+ if tt, aa := parseMeminfoBytes(strings.NewReader("garbage")); tt != nil || aa != nil {
+ t.Fatal("unparseable meminfo must yield nil, not zero")
+ }
+}
+
+func TestParseLoad1(t *testing.T) {
+ if l := parseLoad1("0.42 0.36 0.30 1/234 5678\n"); l == nil || *l != 0.42 {
+ t.Fatalf("got %v", l)
+ }
+ if l := parseLoad1(""); l != nil {
+ t.Fatal("empty loadavg must yield nil")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/host/ -run 'Meminfo|Load1' -count=1` → FAIL.
+- [ ] **Step 3: Implement** in probes.go: `parseMeminfoBytes(r io.Reader)` (same scanner shape as readMemFrac, returning bytes = kB×1024), `readMemBytes()` opening `/proc/meminfo` and delegating; `parseLoad1(s string)` (first field of `/proc/loadavg` via `strconv.ParseFloat`), `readLoad1()` reading the file; `readDiskFreeBytes()` via `syscall.Statfs(dataDir(), &st)` returning `int64(st.Bavail) * st.Bsize` (nil on error). Every failure → nil.
+- [ ] **Step 4: Wire into statusLoop's Probes literal** (agent.go ~line 622), after the existing fields:
+
+```go
+memTotal, memAvail := readMemBytes()
+p := status.Probes{
+ // ... existing fields unchanged ...
+ MemTotalBytes: memTotal,
+ MemAvailBytes: memAvail,
+ Load1: readLoad1(),
+ DiskFreeBytes: readDiskFreeBytes(),
+ Release: version.Build,
+ Tunnel: tunnelState(),
+}
+```
+
+(Import `"github.com/carlosframework/platform/internal/version"`.)
+- [ ] **Step 5: Run tests** — `go test ./internal/host/ -count=1` → PASS.
+- [ ] **Step 6: Full gate + commit** — `host: raw metric probes (mem/load/disk/release/tunnel) into the heartbeat Box`.
+
+---### Task 7: Render metrics + staleness — console fleet view and `carlos status -live`
+
+**Files:**
+- Modify: `internal/status/fleet.go` (export a box loader), `internal/console/view.go` + `internal/console/server.go` (fleet view, anchor `view.go:344` FleetView / `server.go:961` handleFleet — read both first), `cli.go` (RunStatus ~line 1587, statusUsage ~line 1568)
+- Test: `internal/status/fleet_test.go`, plus the console package's existing fleet-view test file (read and extend in place)
+
+**Interfaces:**
+- Consumes: `Box.Metrics` (Task 5), existing `loadBoxes` (`fleet.go:145`), staleness rule (`now - GeneratedAt > 3×period`, `fleet.go:115`).
+- Produces: `status.LoadBoxes(ctx, s) ([]Box, error)` (exported wrapper over loadBoxes — one loader, one skip-corrupt rule, no second parser); `status.BoxStale(b Box, now time.Time) bool` using the same 3× constant; CLI `carlos status -live` reading `CARLOS_STATUS_BUCKET`/`CARLOS_DEPLOYMENT_BUCKET` via the same `resolveStatusBucket` helper agent.go's statusLoop uses.
+
+- [ ] **Step 1: Write the failing test** (fleet_test.go):
+
+```go
+func TestLoadBoxesAndStaleness(t *testing.T) {
+ s := testDirStore(t) // the DirStore helper the package's tests already use
+ fresh := Box{GeneratedAt: time.Now(), Label: "ada-stargazing",
+ Metrics: &BoxMetrics{Release: "abc1234", Tunnel: "connected"}}
+ dark := Box{GeneratedAt: time.Now().Add(-time.Hour), Label: "grace-kayaking"}
+ must(t, WriteBox(context.Background(), s, fresh))
+ must(t, WriteBox(context.Background(), s, dark))
+ boxes, err := LoadBoxes(context.Background(), s)
+ if err != nil || len(boxes) != 2 {
+ t.Fatalf("LoadBoxes: %v, %d boxes", err, len(boxes))
+ }
+ for _, b := range boxes {
+ wantStale := b.Label == "grace-kayaking"
+ if BoxStale(b, time.Now(), 5*time.Minute) != wantStale {
+ t.Fatalf("%s: stale = %v, want %v", b.Label, !wantStale, wantStale)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** → FAIL (undefined: LoadBoxes).
+- [ ] **Step 3: Implement** in fleet.go: `func LoadBoxes(ctx context.Context, s release.Store) ([]Box, error) { return loadBoxes(ctx, s) }`; hoist the 3× rule into `const staleFactor = 3` + `func BoxStale(b Box, now time.Time, period time.Duration) bool { return now.Sub(b.GeneratedAt) > staleFactor*period }`. BuildFleet's inline computation at `fleet.go:115` switches to calling it so the two can never drift.
+- [ ] **Step 4: Console.** In the fleet view (FleetView/handleFleet), add a per-box "Hosts" detail: call `status.LoadBoxes` against the store handle the console already holds for fleet.json, render per box — label, region, release, tunnel state, `mem avail/total` (humanized), load1, disk free — and when `BoxStale`: render the box as **unreachable** (distinct style + the age of the last heartbeat) and suppress the metric numbers entirely (an unreachable host must never show its last happy numbers — spec). Follow the surrounding template/view conventions exactly (vendored Lucide icons only, no CDN). Extend the existing console fleet-view test with: a stale box renders "unreachable" and does NOT render its stale metric values; a fresh box renders them.
+- [ ] **Step 5: CLI.** Replace RunStatus's dry-run-only shape with two modes (keep `-dry-run` byte-identical; update statusUsage — it currently *documents* having no live mode, and that sentence must go):
+
+```go
+liveFlag := fs.Bool("live", false, "read status/boxes/*.json from the status bucket and print per-host metrics")
+// ...
+if *liveFlag {
+ if err := statusLive(os.Stdout); err != nil {
+ log.Fatal(err)
+ }
+ return
+}
+```
+
+```go
+// statusLive prints one line per box: label, region, release, tunnel state,
+// memory, load, disk — or UNREACHABLE (+heartbeat age) for a stale box,
+// never its last happy numbers. Operator credentials; same bucket
+// resolution as the agent's own status loop.
+func statusLive(w io.Writer) error {
+ ctx := context.Background()
+ bucket := host.ResolveStatusBucket(os.Getenv) // export the existing helper from internal/host
+ if bucket == "" {
+ return fmt.Errorf("set CARLOS_STATUS_BUCKET or CARLOS_DEPLOYMENT_BUCKET")
+ }
+ s, err := release.NewS3Store(ctx, bucket)
+ if err != nil {
+ return err
+ }
+ boxes, err := status.LoadBoxes(ctx, s)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ for _, b := range boxes {
+ if status.BoxStale(b, now, 5*time.Minute) {
+ fmt.Fprintf(w, "%-24s %-12s UNREACHABLE (last heartbeat %s ago)\n",
+ b.Label, b.Region, now.Sub(b.GeneratedAt).Round(time.Minute))
+ continue
+ }
+ m := b.Metrics
+ if m == nil {
+ fmt.Fprintf(w, "%-24s %-12s ok (no metrics block — pre-upgrade host)\n", b.Label, b.Region)
+ continue
+ }
+ fmt.Fprintf(w, "%-24s %-12s release=%s tunnel=%s mem=%s/%s load=%s disk-free=%s\n",
+ b.Label, b.Region, orDash(m.Release), orDash(m.Tunnel),
+ humanBytes(m.MemAvailBytes), humanBytes(m.MemTotalBytes),
+ humanFloat(m.Load1), humanBytes(m.DiskFreeBytes))
+ }
+ return nil
+}
+```
+
+(Write the tiny `orDash`/`humanBytes`/`humanFloat` helpers beside it — nil renders as `-`. Rename/export `resolveStatusBucket` → `host.ResolveStatusBucket` with a wrapper keeping the internal callsite compiling.)
+- [ ] **Step 6: Run everything** — `go test ./internal/status/ ./internal/console/ ./... -count=1` → PASS.
+- [ ] **Step 7: Full gate + commit** — `status/console/cli: render raw host metrics privately, stale hosts as unreachable`.
+
+---
+
+### Task 8: Off-cloud bootstrap (`carlos bootstrap -offcloud`) + runbook
+
+**Files:**
+- Modify: `internal/host/bootstrap.go` (RunBootstrap ~line 89, bootstrap ~line 104)
+- Create: `infra/remote-host.md`
+- Test: `internal/host/bootstrap_test.go` (read the existing staging-root tests first, extend in place)
+
+**Interfaces:**
+- Consumes: existing `bootstrap(root)` idempotent flow.
+- Produces: `-offcloud` flag; contract: credentials file at `<root>/etc/carlos/credentials` (mode 0600, owned by the service user on a live run) must exist BEFORE bootstrap proceeds; host.env must carry `AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials` (checked and printed, not silently assumed).
+
+- [ ] **Step 1: Write the failing test** (staging-root path — no root privileges in tests):
+
+```go
+func TestBootstrapOffcloudRequiresCredentialsFile(t *testing.T) {
+ root := t.TempDir()
+ if err := bootstrapOffcloud(root); err == nil {
+ t.Fatal("want error when <root>/etc/carlos/credentials is absent")
+ }
+ credDir := filepath.Join(root, "etc", "carlos")
+ os.MkdirAll(credDir, 0o755)
+ credFile := filepath.Join(credDir, "credentials")
+ os.WriteFile(credFile, []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\n"), 0o644)
+ if err := bootstrapOffcloud(root); err == nil {
+ t.Fatal("want error on 0644 credentials (must be 0600)")
+ }
+ os.Chmod(credFile, 0o600)
+ if err := bootstrapOffcloud(root); err != nil {
+ t.Fatalf("offcloud bootstrap with staged 0600 credentials: %v", err)
+ }
+ // The normal bootstrap artifacts must exist too — offcloud is a superset.
+ if _, err := os.Stat(filepath.Join(root, "etc", "systemd", "system", "carlos-edge.service")); err != nil {
+ t.Fatalf("units not installed: %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** → FAIL (undefined: bootstrapOffcloud).
+- [ ] **Step 3: Implement.**
+
+```go
+// bootstrapOffcloud is bootstrap for a box with no EC2 instance profile
+// (issue #53 point 4): identical structural work, plus the credential
+// preconditions — a staged 0600 credentials file scoped (at IAM) to the
+// deployment bucket + this host's own region bucket and nothing else, so
+// wrong-region-fails-at-IAM holds off-cloud too. The flag is explicit; no
+// IMDS sniffing (the agent's one IMDS probe is already gated for non-EC2).
+func bootstrapOffcloud(root string) error {
+ cred := filepath.Join(root, "etc", "carlos", "credentials")
+ fi, err := os.Stat(cred)
+ if err != nil {
+ return fmt.Errorf("offcloud: stage the scoped credentials file first: %s (0600, see infra/remote-host.md): %w", cred, err)
+ }
+ if fi.Mode().Perm() != 0o600 {
+ return fmt.Errorf("offcloud: %s must be mode 0600, is %o", cred, fi.Mode().Perm())
+ }
+ if err := bootstrap(root); err != nil {
+ return err
+ }
+ if filepath.Clean(root) == "/" {
+ if err := chownToService(cred); err != nil { // same helper the data dirs use
+ return err
+ }
+ }
+ fmt.Printf("offcloud bootstrap complete.\n ensure /etc/carlos/host.env sets:\n AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials\n then stage /opt/carlos/carlos and start carlos-edge.service (see infra/remote-host.md)\n")
+ return nil
+}
+```
+
+ Wire the flag in RunBootstrap: `offcloud := fs.Bool("offcloud", false, "off-cloud box: require a staged 0600 credentials file instead of an EC2 instance profile")`, dispatching to `bootstrapOffcloud(*root)` when set. If `chownToService`'s signature differs (read it at ~line 230), adapt the call — the intent is: service user owns the file, still 0600.
+- [ ] **Step 4: Run tests** — `go test ./internal/host/ -run Bootstrap -count=1` → PASS (and the pre-existing bootstrap tests still pass).
+- [ ] **Step 5: Write `infra/remote-host.md`** — the one-page software-half runbook (issue #53's deliverable; the hq PHYSICAL half lands later in `infra/tito/region-hq.md`):
+
+```markdown
+# Remote host runbook (software half)
+
+A CARLOS host is anything that can reach the deployment bucket. A remote
+host is OUTBOUND-ONLY: its whole network surface is outbound 443 to S3 and
+the edge. No inbound port, no SSH path required by the platform.
+
+Prereqs: systemd Debian-family OS, 64-bit (the artifact key is
+GOOS-GOARCH — linux-arm64 or linux-amd64; a 32-bit OS resolves nothing).
+
+1. Provision the IAM principal + region data bucket
+ (infra/modules/region-remote-host). Scope: deployment bucket + this
+ host's own region bucket, NOTHING else. IAM Roles Anywhere is the
+ long-term option; a scoped access key is acceptable to start.
+2. Stage /etc/carlos/credentials (0600):
+ [default]
+ aws_access_key_id=...
+ aws_secret_access_key=...
+3. Stage /etc/carlos/host.env — the usual agent env plus:
+ AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials
+ CARLOS_TUNNEL_URL=https://<reserved-tunnel-hostname>
+ CARLOS_TUNNEL_TOKEN=<this host's token>
+ CARLOS_TUNNEL_NAME=<this host's channel name>
+4. Stage the binary at /opt/carlos/carlos (matching GOOS-GOARCH).
+5. Run: carlos bootstrap -offcloud
+6. systemctl start carlos-edge.service
+7. Verify: journalctl -u carlos-edge shows "tunnel connected"; the box
+ appears in `carlos status -live` within one 5-minute heartbeat.
+
+Edge side: set CARLOS_TUNNEL_HOST + CARLOS_TUNNEL_CLIENTS
+(name=token,...) in the edge box's host.env; back routes with
+`carlos route -host <h> -tunnel <name>`.
+```
+
+- [ ] **Step 6: Full gate + commit** — `host: carlos bootstrap -offcloud + remote host runbook (software half)`.
+
+---
+
+### Task 9: Failover semantics — the dark-then-returns lease test
+
+**Files:**
+- Modify: `internal/lease/lease.go` (Acquire ~line 177 — extract a TTL seam)
+- Test: `internal/lease/lease_test.go` (live-gated harness: `liveConfig(t)` skips without `CARLOS_TEST_S3_BUCKET`)
+
+**Interfaces:**
+- Consumes: existing `Acquire`, `HeldError`, `liveConfig` harness.
+- Produces: unexported `acquireTTL(ctx, bucket, prefix, region string, ttl time.Duration) (*Held, error)`; `Acquire` becomes `return acquireTTL(ctx, bucket, prefix, region, LeaseTTL)`. No exported surface changes.
+
+Scope note: the wake-elsewhere half of failover (a fallback host's activator waking the instance from the regional bucket on request) is the EXISTING activator wake path, already exercised by the activator's own tests — nothing new to build or test there (issue #53: "verify it rather than build it"). What is genuinely new to prove is the returning-dark-host-must-lose property below.
+
+- [ ] **Step 1: Extract the seam** — mechanical: move Acquire's body into `acquireTTL` with `leaser.TTL = ttl`; Acquire delegates with `LeaseTTL`. Run `go test ./internal/lease/ -count=1` → PASS (everything skips or passes as before).
+- [ ] **Step 2: Write the dark-then-returns test** (proves issue #53 point 3: a returning host must NOT reclaim a lease now held elsewhere — the conditional-write race guarantees it; this test is the proof for the dark-then-returns sequence specifically):
+
+```go
+// TestDarkHostCannotReclaimAfterFailover: host A holds the lease and goes
+// dark (stops renewing, never releases). After TTL expiry, host B claims it
+// (the failover wake). A returns and tries to re-acquire: it MUST lose with
+// *HeldError naming B's ownership — never silently reclaim.
+func TestDarkHostCannotReclaimAfterFailover(t *testing.T) {
+ bucket, prefix, region := liveConfig(t)
+ ctx := context.Background()
+ const shortTTL = 15 * time.Second
+
+ a, err := acquireTTL(ctx, bucket, prefix, region, shortTTL)
+ if err != nil {
+ t.Fatalf("A initial acquire: %v", err)
+ }
+ // A goes dark: stop renewal WITHOUT releasing (the crash/power-cut shape).
+ // rn.stop() halts the renewer but does not touch the lock object in S3.
+ a.rn.stop()
+
+ // While A's lease is still live, B must be refused — dark ≠ expired.
+ if _, err := acquireTTL(ctx, bucket, prefix, region, shortTTL); err == nil {
+ t.Fatal("B acquired while A's un-expired lease still held")
+ }
+
+ time.Sleep(shortTTL + 5*time.Second) // let A's lease expire
+
+ b, err := acquireTTL(ctx, bucket, prefix, region, shortTTL)
+ if err != nil {
+ t.Fatalf("B acquire after expiry (the failover wake): %v", err)
+ }
+ defer b.Release(ctx)
+
+ // A returns from the dead and tries to pick up where it left off.
+ _, err = acquireTTL(ctx, bucket, prefix, region, shortTTL)
+ var held *HeldError
+ if !errors.As(err, &held) {
+ t.Fatalf("returning dark host got %v, want *HeldError (must lose the race)", err)
+ }
+}
+```
+
+- [ ] **Step 3: Run gated** — without `CARLOS_TEST_S3_BUCKET`: `go test ./internal/lease/ -count=1` → SKIP cleanly. With it set (if operator credentials are available in the session): the test passes in ~30s. Record in the PR description whether the live run was executed.
+- [ ] **Step 4: Full gate + commit** — `lease: prove the dark-then-returns failover race (returning host must lose)`.
+
+---
+
+### Task 10: E2E — edge + fake remote host over a real reverse channel, reconnect bound
+
+**Files:**
+- Create: `internal/edge/tunnel_e2e_test.go`
+
+**Interfaces:**
+- Consumes: everything from Tasks 1–4. This is issue #53's acceptance test, in-repo, no network beyond loopback.
+
+- [ ] **Step 1: Write the e2e test:**
+
+```go
+package edge
+
+// The acceptance test issue #53 names: edge + fake remote host over a REAL
+// reverse channel on loopback. Round-trip, header survival, kill-mid-flight,
+// re-dial, and the reconnect bound (asserted, not narrated).
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+const e2eReconnectBound = 5 * time.Second
+
+func TestTunnelEndToEnd(t *testing.T) {
+ reg := testRegistryWithRoute(t, registry.Route{
+ Host: "demo.example", Kind: "instance", Status: "ready", Tunnel: "pi-box",
+ })
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
+ e.EnableTunnels(hub)
+ if err := e.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ // The edge listens on loopback; both app traffic and the tunnel
+ // registration ride this one listener — the no-new-port property.
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ // Fake remote host: its "local edge" answers with a header + body, and
+ // echoes X-Forwarded-For so the test can prove header discipline held.
+ appHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-App-Header", "survived")
+ w.Header().Set("Carlos-Purge", "/should-be-stripped") // edge must strip
+ fmt.Fprintf(w, "host=%s xff=%s", r.Host, r.Header.Get("X-Forwarded-For"))
+ })
+ stop := startFakeRemoteHost(t, srv.URL, "pi-box", "sekrit", appHandler)
+ defer stop()
+
+ waitForState(t, hub, "pi-box", "connected", e2eReconnectBound)
+
+ do := func() *http.Response {
+ req, _ := http.NewRequest("GET", srv.URL+"/", nil)
+ req.Host = "demo.example"
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request through tunnel: %v", err)
+ }
+ return resp
+ }
+ resp := do()
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != 200 || resp.Header.Get("X-App-Header") != "survived" {
+ t.Fatalf("round-trip: %d %v", resp.StatusCode, resp.Header)
+ }
+ if resp.Header.Get("Carlos-Purge") != "" {
+ t.Fatal("Carlos-Purge leaked to the client — purge discipline broken over tunnel")
+ }
+ if !strings.Contains(string(body), "host=demo.example") || !strings.Contains(string(body), "xff=127.") {
+ t.Fatalf("host/XFF discipline broken: %q", body)
+ }
+
+ // Kill the channel out from under the host (edge side drops it), then
+ // assert the host re-dials and the NEXT request succeeds within bound.
+ hub.mu.Lock()
+ hub.conns["pi-box"].raw.Close()
+ delete(hub.conns, "pi-box")
+ hub.mu.Unlock()
+
+ start := time.Now()
+ waitForState(t, hub, "pi-box", "connected", e2eReconnectBound)
+ if resp := do(); resp.StatusCode != 200 {
+ t.Fatalf("post-reconnect request: %d", resp.StatusCode)
+ }
+ if d := time.Since(start); d > e2eReconnectBound {
+ t.Fatalf("reconnect took %s, bound is %s", d, e2eReconnectBound)
+ }
+}
+
+func waitForState(t *testing.T, hub *TunnelHub, name, want string, within time.Duration) {
+ t.Helper()
+ deadline := time.Now().Add(within)
+ for time.Now().Before(deadline) {
+ if hub.State(name) == want {
+ return
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatalf("tunnel %s never reached state %q within %s", name, want, within)
+}
+```
+
+`startFakeRemoteHost` reuses the HOST-side client for real: it must call `internal/host`'s actual dial+serve+backoff loop, not a reimplementation — otherwise the reconnect bound proves nothing. `internal/host` already imports `internal/edge`, so the edge test can't import host (cycle). Resolve it the repo-idiomatic way: extract the host's dial/serve/backoff core (`dialTunnel`, `serveTunnel`, `bufferedConn`, the retry loop, backoff constants) into a tiny new leaf package `internal/tunnelclient` (no imports beyond stdlib + x/net), with `internal/host/tunnel_client.go` becoming a thin wrapper that reads env, stores state via a `func(string)` callback, and calls it. Then `startFakeRemoteHost` runs `tunnelclient.Loop(ctx, url, name, token, handler, onState)` in a goroutine and returns a cancel func. Update Task 4's files accordingly when executing this task (the tests written there keep passing — they exercise the same functions through the new package).
+- [ ] **Step 2: Run it** — `go test ./internal/edge/ -run TestTunnelEndToEnd -count=1 -v` → PASS, reconnect measured under 5s.
+- [ ] **Step 3: Race check** — `go test ./internal/edge/ ./internal/tunnelclient/ -count=1 -race` → PASS.
+- [ ] **Step 4: Full gate + commit** — `edge: e2e reverse-channel acceptance — round-trip, header discipline, reconnect bound`.
+
+---
+
+### Task 11: Final verification + draft PR
+
+- [ ] **Step 1:** `go build ./... && go vet ./... && go test ./... -count=1` — all packages (now 15+ with tunnelclient; CLAUDE.md's "14 packages" line is stale the moment tunnelclient lands — update CLAUDE.md's count in this commit). `gofmt -l` on every touched file → empty.
+- [ ] **Step 2:** `go test ./internal/release/ -run Ladder -count=1` → the pinned production channel names still pass.
+- [ ] **Step 3:** Push branch, open DRAFT PR titled "Remote host-agent: tunnel route backing, heartbeat metrics, off-cloud bootstrap (#53)". Body: link issue #53 + the spec; list the three recorded divergences (top of this plan); state which acceptance tests ran live vs gated (Task 9). End with the Claude Code attribution footer.
+- [ ] **Step 4:** Request Opus review (per CLAUDE.md) on: `internal/edge/tunnel.go` (auth, hijack lifecycle), the Reload/ServeHTTP changes, `internal/tunnelclient`, and the lease test.
+- [ ] **Step 5:** Once PR #49 (plan-c update) is merged to main: tick C6 Step 3 in `docs/superpowers/plans/2026-08-01-plan-c-tito-selfhosted.md` and record the divergences there. If #49 is still open at merge time, leave a comment on #49 instead so the tick isn't lost.