# P2 — Host Agent (`agent/`) — Implementation Plan

> **Plan of the 6** (see [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) — the coordination
> point / analog of [`src/types.ts`](../src/types.ts)). This plan owns the **`agent/` package only**.
> **Source of intent**: [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) §0 LOCKED DECISIONS
> (AUTHORITATIVE, CLOSED) + §3 host-agent lifecycle + §6 distribution.
> **Style**: [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md) — stable task
> IDs grouped into dependency waves, an `Owns:` disjoint-file list per task, function-signature-level
> contracts, TDD (tests FIRST), explicit test cases (incl. security/negative), per-task security notes.
> **All FROZEN SHARED CONTRACTS are cited by INDEX §-number and NEVER redefined here** — the agent
> `import`s them read-only from `relay-contracts/`.
> `PROGRESS_LOG.md` is **orchestrator-only** (subagents return a ready-to-paste entry, G1).

---

## 0. Scope & non-scope

**In scope (the `agent/` package, EXPLORE §3 "Host-agent lifecycle" + §6 "Distribution"):**

1. **Enrollment / pairing redemption** — INDEX **§4.5**: `npx web-terminal-agent pair ABCD-1234` generates
   an **Ed25519 keypair LOCALLY** (private key never leaves the host, INV4), redeems a **single-use,
   short-TTL** pairing code, receives the frozen §4.5 `EnrollResult`
   `{ host_id, subdomain, cert, caChain, hostContentSecret }` (FIX 3 — the last field wrapped to the agent
   identity, unwrapped + stored locally for recoverable replay).
2. **Outbound dial + mTLS** — `wss://relay/agent` on 443 (CGNAT-friendly, EXPLORE §3), mutually
   authenticated with the per-host key + short-lived **auto-rotating** cert (INV14).
3. **Registration** — relay writes `route:{host_id}` in Redis (P3 side); the agent proves key
   possession and holds the link.
4. **Holding the §4.1 mux tunnel** — one physical outbound `wss://` carrying N sessions × M mirrored
   clients + heartbeat; frame demux/encode via the **frozen §4.1 codec** (imported from
   `relay-contracts/`, never re-implemented).
5. **Forwarding logical streams to the UNCHANGED web-terminal** at `ws://127.0.0.1:3000` — one
   `OPEN` ⇒ one fresh loopback socket, replaying the real browser `Origin` (§4.1 `MuxOpen.originHeader`).
6. **Agent-side E2E endpoint** — INDEX **§4.4**: the host side of the handshake (`host_hello`, transcript
   `sig` under the enrollment key) yielding a **`HandshakeResult` with direction-split `DirectionalKeys`
   (`c2h`/`h2c`)** (FIX 2 — **no** single `sessionKey: CryptoKey`), driven by P4's `createHostHandshake`
   with the **injected** device-auth-proof verifier (FIX 6b); per-stream **synchronous** `sealFrame`/
   `openFrame` over `AeadKey`; and — distinct from live `h2c` frames — sealing every **replay-bound**
   host→client output under the recoverable `K_content` via `sealReplayFrame` (FIX 3). The agent wires the
   **P4 `relay-e2e/` crypto core** (imported, not written here). The agent is one E2E endpoint; plaintext
   exists **only on the customer's own machine**.
7. **Reconnection / backoff** — reuse the **1/2/4…cap-30s** policy the base app already ships
   (EXPLORE §3; mirrors `public/` reconnect), with jitter; 15s heartbeat miss ⇒ tunnel dead ⇒ reconnect.
8. **Distribution** — `npx web-terminal-agent pair` (MVP) → single static binary (`bun --compile`);
   **install-as-service** (launchd on macOS, systemd on Linux).

**Explicitly NOT in scope (owned by other plans — do not touch):**
- The **§4.1 frame codec itself**, stream-id allocation, per-stream flow-control *protocol*, and the
  relay data plane → **P1 TRANSPORT** (`term-relay/`). The agent is a **consumer** of §4.1.
- **Account/host registries, pairing-code issuance, subdomain assignment, the mTLS CA/cert signing,
  routing table** → **P3 CONTROL PLANE**. The agent submits a CSR and *installs* the returned cert.
- **The E2E crypto primitives** (`sealFrame`/`openFrame`, handshake state machine, HKDF) → **P4
  `relay-e2e/`**. The agent wires them into its splice.
- **Capability-token issuance/verification, human auth, the CI cross-tenant tripwire, revocation
  policy/DB** → **P5**. The agent only *reacts* to revocation (tears the tunnel down) and never verifies
  capability tokens (the relay authorized the stream before it reached the agent — §4.1 OPEN note).
- **Browser UI, client-side preview render** → **P6**.
- **`src/` and `public/`** — byte-for-byte unchanged. The single base-app touch-point (`ALLOWED_ORIGINS`
  gains the subdomain) is applied **by the agent installer at install time as a config/env write**, not
  a code edit (INDEX §0, EXPLORE §3 "one base-app touch-point").

---

## 1. SECURITY INVARIANTS this plan enforces (INDEX §3)

Per INDEX §3, this plan lists the invariants it owns and **ships a test per owned invariant**:

| INV | How `agent/` enforces it | Owning task(s) |
|---|---|---|
| **INV1** — cross-tenant isolation (agent-side **defense-in-depth**; primary enforcement is P1/P5) | `handleOpen` compares `MuxOpen.subdomain` (§4.1) against the agent's own enrolled `AgentConfig.subdomain` **before** dialing loopback; any mismatch ⇒ RST the stream, never dial, audit-log (metadata-only, INV10). The one place the agent can catch a relay that cross-wires host B's OPEN into host A's tunnel. | T8 |
| **INV2** — relay handles only ciphertext | Agent seals output → §4.1 `DATA` before it enters the tunnel; the relay only ever sees §4.4 `E2EEnvelope` bytes. Live frames use the ephemeral `DirectionalKeys.h2c`; **replay-bound** output is ALSO sealed under the recoverable `K_content` (`sealReplayFrame`, FIX 3) — still ciphertext to the relay. Plaintext lives only host-local (loopback ↔ agent). Handshake is device-auth-proof-gated (T15 aborts on a forged `ClientHello`, no key derived). | T15, T19 |
| **INV4** — no shared secrets; per-host asymmetric identity; DB stores only public keys | Ed25519 keypair generated **locally**; private key persisted `0600`, **never transmitted** (only pubkey + CSR leave); mTLS proves possession without sending the key. | T3, T4, T12 |
| **INV5** — no plaintext/secret at rest (agent-local) | Private key + cert stored with restrictive perms; raw pairing code **never written to disk**; no plaintext shell bytes buffered (pure shuttle). | T3, T4, T8 |
| **INV9** — secrets in env/secret-manager, validated at startup, never logged | Config validated with Zod at boot; fail-fast on missing/invalid key material; structured logger redacts key/cert/code fields; no `console.log`. | T2, T18 |
| **INV11** — no terminal parsing in the agent (ciphertext/byte-shuttle discipline) | The agent moves **opaque** bytes; imports **no** xterm/ANSI parser; even after E2E `openFrame` the decrypted bytes are treated as opaque to the loopback WS. | T7, T8, T15 |
| **INV12** — fast revocation kills live tunnels in seconds | On a `'revoked'` `GOAWAY` (distinguished from the graceful `'operatorDrain'`/`'shutdown'` reasons via the **frozen** 3-value `relay-contracts` `GoAwayReason`/`decodeGoAwayReason`, never a local numeric guess; unknown reason ⇒ fail-closed to revoked) or cert-renewal refusal, the agent tears down all streams + the tunnel immediately and stops reconnect for a `revoked` host. | T13, T14 |
| **INV14** — mTLS with SPIFFE-style short-lived, auto-rotating certs | Dial uses the client cert; a rotation timer renews **before** expiry via the P3 renewal endpoint; expired/absent cert ⇒ no dial. | T12, T13 |

**Cross-cutting discipline** (INDEX §0, coding-style): immutable snapshots (tunnel/stream state swapped
atomically, never mutated in place); many small files (200–400 lines, 800 hard max); high cohesion / low
coupling; TypeScript, `camelCase` fns / `PascalCase` types / string-literal unions over enums; **Zod
validation at every boundary** (pairing response, `MuxOpen`, config); explicit error handling (no
swallowed errors); **no `console.log`**; TDD 80%+ coverage.

---

## 2. Files & ownership (the `agent/` package — disjoint from all other plans)

```
agent/
  package.json                         T1   scaffold (bin: web-terminal-agent)
  tsconfig.json                        T1
  vitest.config.ts                     T1
  src/
    cli.ts                             T5   arg parse → pair | run | status | install | uninstall
    config/agentConfig.ts              T2   AgentConfig Zod schema + load/validate + paths
    transport/seams.ts                 T2   W0 shared seam types: WsLike, RevocationState, RevokeReason (cycle breaker)
    keys/identity.ts                   T3   Ed25519 keygen, load, fingerprint (enroll_fpr, §4.2)
    keys/keystore.ts                   T3   0600 on-disk read/write of key + cert (INV5)
    enroll/pair.ts                     T4   §4.5 REDEEM/RETURN client; CSR build
    enroll/csr.ts                      T4   PKCS#10 CSR from the Ed25519 key
    transport/dial.ts                  T12  outbound mTLS wss dial (client cert)
    transport/tunnel.ts                T7   holds one §4.1 tunnel: demux loop, encode, GOAWAY
    transport/streamRouter.ts          T8   streamId → loopback socket map; OPEN/DATA/CLOSE/RST
    transport/loopback.ts              T8   dial ws://127.0.0.1:3000<path>, replay Origin, splice
    transport/heartbeat.ts             T9   PING/PONG every 15s; miss ⇒ dead
    transport/backoff.ts               T10  1/2/4…cap-30s + jitter reconnection policy
    transport/flowControl.ts           T11  per-stream credit (§4.1 WINDOW_UPDATE) consumer
    transport/frpScaffold.ts           T6   v0.8 frpc-wrap stepping-stone (retired at v0.9)
    e2e/hostEndpoint.ts                T15  §4.4 host side: host_hello + seal/open splice transform
    e2e/replaySeal.ts                  T19  §4.4 replay surface: K_content seal of replay-bound output (FIX 3)
    certs/rotation.ts                  T13  short-lived cert renewal timer (INV14)
    lifecycle/revocation.ts            T14  GOAWAY/revoke → teardown, refuse reconnect (INV12)
    service/install.ts                 T17  dispatch by platform
    service/launchd.ts                 T17  macOS plist writer + load/unload
    service/systemd.ts                 T17  Linux unit writer + enable/start
    service/originConfig.ts            T17  writes ALLOWED_ORIGINS touch-point (base-app config)
    dist/buildBinary.ts                T16  bun --compile config + npx wrapper
    log/logger.ts                      T2   structured redacting logger (no console.log, INV9)
  test/
    <mirrors src, one *.test.ts per module>
    fixtures/                          shared test vectors (pairing resp, MuxOpen frames)
```

**Imports (read-only) from other plans' frozen artifacts** — never redeclared here:
- `relay-contracts/` (INDEX §4): `encodeMuxFrame`/`decodeMuxFrame`, `MuxFrameHeader`, `MuxOpen`,
  `HostRecord`, `HostStatus`, pairing Zod schemas + **§4.5 `EnrollResult`** (now carrying `hostContentSecret`,
  FIX 3); the **frozen §4.4 E2E surface** cited verbatim — `AeadAlg`, `AeadKey` (opaque wrapper, **not**
  `CryptoKey`), `ClientHello`, `HostHello`, `E2EEnvelope`, `DirectionalKeys` (`c2h`/`h2c`), `HandshakeResult`,
  `E2ESession`, `createE2ESession`, and the **synchronous** `sealFrame`/`openFrame`; the **§4.4 replay surface**
  `ReplayKeyParams`/`deriveContentKey`/`sealReplayFrame`/`REPLAY_KDF_INFO` (FIX 3); and the **frozen §4.1**
  `GoAwayReason` (`'operatorDrain' | 'revoked' | 'shutdown'` string-literal union) + `decodeGoAwayReason`
  (numeric reason-code → union: `1→operatorDrain · 2→revoked · 3→shutdown`, else throws), consumed **verbatim**
  by both P1 (sender) and P2/T14 (receiver); the agent never invents any of these shapes or the numeric mapping.
- `relay-e2e/` (P4): the crypto **impls** re-exported for §4.4 — `sealFrame`, `openFrame`, `sealReplayFrame`,
  `deriveContentKey`, and the **host-handshake wiring `createHostHandshake({ verifyDeviceProof, … })`**.
  **FIX 6b:** the device-auth-proof **verifier is P5-owned** and reaches the agent **injected through P4's
  `createHostHandshake`** — the agent **does NOT `import { verifyDeviceAuthProof } from 'relay-e2e'`** (that
  old T15 import was wrong). The verifier is bound to `{ clientEphPub, clientNonce }` (§4.4 `ClientHello`),
  unified across P2/P4/P5. (`agent/e2e/hostEndpoint.ts` + `agent/e2e/replaySeal.ts` wire these; they contain
  **no** crypto primitives and **no** local verifier.)

**Intra-`agent/` shared seam (NOT a cross-plan contract):** `agent/src/transport/seams.ts` (T2, W0) declares
`WsLike`, `RevocationState`, `RevokeReason` **once**; T7/T10/T12/T14 import them (kills type drift and the
former T10↔T14 task cycle). Frozen cross-plan contracts still live only in `relay-contracts/`.

`agent/` **owns none of** `relay-contracts/`, `term-relay/`, `relay-e2e/`, `relay-web/`, `src/`, `public/`.

---

## 3. Waves & tasks

```
W0  scaffold + boundaries          T1 pkg · T2 config+logger+seams · T3 identity/keystore
      │                                 (T2 seams.ts freezes WsLike + RevocationState at W0 —
      │                                  the cycle-breaker every W2/W3 transport task imports)
      ▼
W1  enrollment (v0.8→v0.9)         T4 pairing/CSR · T5 CLI · T6 frp scaffold (v0.8 only)
      │
      ▼
W2  native tunnel (v0.9)           T7 tunnel · T8 streamRouter+loopback · T9 heartbeat
                                   T10 backoff · T11 flow-control     (all consume P1 §4.1)
      │                            (T10 consumes only the W0 isRevoked seam — NO edge to T14)
      ▼
W3  mTLS + lifecycle (v0.9/v0.10)  T12 mTLS dial · T13 cert rotation · T14 revocation/GOAWAY
      │                            (T14 wires T10's loop one-way; depends on T7/T13, not T10)
      ▼
W4  E2E endpoint (v0.10)           T15 host_hello + seal/open splice · T19 replay-frame sealer (K_content)
      │                            (both consume P4 relay-e2e/; HARD GATE: BLOCKED until the injected
      │                             device-proof verifier + forged-proof vector land — Q#2)
      ▼
W5  distribution + accept          T16 static binary · T17 service install+origin · T18 acceptance
```

**Wave-ordering integrity (no back-edges):** every task depends **only on earlier or same-wave** tasks.
The former T10↔T14 cycle (and the W2→W3 back-edge it implied) is **removed**: the revocation short-circuit
is an **injected `isRevoked: () => boolean` seam frozen in W0 `seams.ts`**, so T10 (W2) depends on T2 (W0)
alone for it, and T14 (W3) wires T10's loop in a single forward direction (W3 consuming a W2 function is
legal; the reverse is not). A builder can topologically order the plan as written.

**Cross-plan dependency ordering (INDEX §2):** `relay-contracts/` (§4) is program-W0 and must be frozen
before this plan's W0 — **including the frozen §4.1 3-value `GoAwayReason`** (`operatorDrain`/`revoked`/`shutdown`) consumed by T14.
**P1 TRANSPORT** §4.1 must exist before W2 (in v0.8 the T6 frp scaffold lets W1/W5 proceed against a stub;
the native-mux swap in v0.9 is contract-compatible by construction). **P3 CONTROL PLANE** `/enroll` +
cert-renewal + `/agent` endpoints must exist before W1 REDEEM (T4) and W3 dial (T12/T13) integrate — unit
tests mock them. **P4 `relay-e2e/`** must exist before W4 (T15/T19) — and specifically P5's frozen
device-proof verifier **wired into P4's `createHostHandshake`** (FIX 6b — P2 consumes it injected, never
imports it) + the cross-plan forged-proof test vector is a **hard pre-W4 gate** (open Q#2), not a soft
follow-up.

---

### W0 · Scaffold + boundaries

#### T1 · Package scaffold `[ ]` — v0.8
- **Owns**: `agent/package.json`, `agent/tsconfig.json`, `agent/vitest.config.ts`
- **Depends**: `relay-contracts/` frozen (INDEX §4). **Parallel-safe**: must be first in this plan.
- **Steps (TDD-lite; config task)**:
  - [ ] `package.json` with `"bin": { "web-terminal-agent": "dist/cli.js" }`; deps: `ws`, `zod`,
        `relay-contracts` (workspace), `relay-e2e` (workspace, W4-only); dev: `typescript`, `tsx`,
        `vitest`, `@types/ws`, `@types/node`. **No xterm / ANSI parser dep** (INV11 static guarantee).
  - [ ] `tsconfig.json` (ES2022, NodeNext, strict, `noEmit` off, outDir `dist`); path alias to
        `relay-contracts`.
  - [ ] `vitest.config.ts` with `coverage.include: ['src/**']`, node environment.
  - [ ] smoke test: `test/scaffold.test.ts` asserts `relay-contracts` imports resolve and **no**
        transitive dep matches `/xterm|ansi|vt100/` (INV11 tripwire).
- **Accept**: `npm --prefix agent test` runs (smoke green); `tsc --noEmit` clean.
- **Security**: INV11 dependency tripwire lives here.

#### T2 · Config + redacting logger + shared seams `[ ]` — v0.8
- **Owns**: `agent/src/config/agentConfig.ts`, `agent/src/log/logger.ts`, `agent/src/transport/seams.ts`,
  `agent/test/agentConfig.test.ts`, `agent/test/logger.test.ts`, `agent/test/seams.test.ts`
- **Depends**: T1. **Parallel-safe**: with T3.
- **Contracts**:
  ```ts
  export interface AgentConfig {
    readonly relayUrl: string          // wss://relay.<domain>/agent — validated wss:// only
    readonly enrollUrl: string         // https://<domain>/enroll — https:// only
    readonly stateDir: string          // where key/cert live (default ~/.web-terminal-agent)
    readonly localTargetUrl: string    // ws://127.0.0.1:3000 (the UNCHANGED web-terminal)
    readonly subdomain: string | null  // filled after pairing
    readonly hostId: string | null     // filled after pairing (§4.2)
  }
  export const AgentConfigSchema: ZodType<AgentConfig>   // wss/https/ws enforced by refine
  export function loadAgentConfig(env: NodeJS.ProcessEnv, argv: Partial<AgentConfig>): AgentConfig
  // logger — redacts key/cert/code/token fields; no console.log
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
  export function createLogger(level: LogLevel): { log(l: LogLevel, msg: string, meta?: Record<string, unknown>): void }
  ```
  **`agent/src/transport/seams.ts` — W0 shared injection seams (DEPENDENCY-CYCLE BREAKER).** These are
  intra-`agent/` seam *types only* (no cross-plan frozen contract lives here — those stay in
  `relay-contracts/`). Declaring them at W0 lets W2/W3 tasks consume a stable type **without a task-level
  cycle** (see the T10↔T14 note in §3):
  ```ts
  // Minimal WS surface the transport layer needs (dial/tunnel/backoff share it; no per-task redeclare).
  export interface WsLike {
    send(d: Uint8Array): void
    on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void
    close(): void
  }
  // Revocation seam — T14 IMPLEMENTS it, T10's reconnectLoop only CONSUMES the isRevoked() callback.
  export type RevokeReason = 'renewal-refused' | 'goaway-revoked' | 'operator'
  export interface RevocationState { isRevoked(): boolean; markRevoked(reason: RevokeReason): void }
  ```
  Both `WsLike` and `RevocationState`/`RevokeReason` are **defined once here** and imported by T7/T10/T12/T14;
  they are **not** redeclared in any W2/W3 task (kills the drift and the cycle at the type level).
- **TDD (RED→GREEN)**:
  - [ ] RED: `relayUrl='http://…'` → throws (only `wss://`); `localTargetUrl` non-loopback host → throws
        (INV: agent forwards **only** to loopback, never an arbitrary target — anti-SSRF).
  - [ ] RED: logger given `{ privateKey, cert, pairingCode, agentToken }` meta → asserts those values
        never appear in emitted string (INV9).
  - [ ] GREEN: implement with Zod `.refine`.
- **Test cases**: valid config parses; missing `relayUrl` → fail-fast; `localTargetUrl=ws://10.0.0.5:3000`
  → rejected (loopback-only); `localTargetUrl=ws://127.0.0.1:3000` → ok; logger redaction of each secret
  field; `debug` meta with a `nonce` key passes through (not a secret); **seams**: `seams.ts` exports
  `WsLike`, `RevocationState`, `RevokeReason` and is `import`-able with **no runtime dependency** (type-only
  file — a test asserts the compiled module has no side effects and no `ws`/crypto import), so W2/W3 can
  depend on it without pulling transport runtime.
- **Security**: INV9 (validated at startup, never logged), loopback-only target (anti-SSRF); the seam file
  is types-only (no secret handling).

#### T3 · Agent identity + keystore `[ ]` — v0.9 (contract stub usable in v0.8)
- **Owns**: `agent/src/keys/identity.ts`, `agent/src/keys/keystore.ts`,
  `agent/test/identity.test.ts`, `agent/test/keystore.test.ts`
- **Depends**: T1, T2. **Parallel-safe**: with T2.
- **Contracts** (uses `node:crypto` `generateKeyPairSync('ed25519')`; **no** third-party crypto):
  ```ts
  export interface AgentIdentity {
    readonly publicKey: Uint8Array         // Ed25519 raw pubkey → stored in host registry (§4.2 agent_pubkey)
    readonly enrollFpr: string             // §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by browser E2E TOFU
    sign(message: Uint8Array): Uint8Array  // uses private key IN-PROCESS; key never returned/serialized off-host
  }
  export function generateIdentity(): AgentIdentity                 // private key held in-memory only
  export function computeEnrollFpr(publicKey: Uint8Array): string
  // keystore — persists ONLY to stateDir with 0600 (INV4/INV5)
  export interface Keystore {
    saveIdentity(id: AgentIdentity): void        // writes private key PEM 0600 + pubkey
    loadIdentity(): AgentIdentity | null
    saveCert(certPem: string, caChainPem: string): void  // 0600
    loadCert(): { certPem: string; caChainPem: string } | null
    saveContentSecret(secret: Uint8Array): void  // 0600 — FIX 3: the §4.5 hostContentSecret, UNWRAPPED with the enrollment key
    loadContentSecret(): Uint8Array | null       // input to §4.4 deriveContentKey (ReplayKeyParams); never logged/egressed
  }
  export function openKeystore(stateDir: string): Keystore
  ```
- **TDD (RED→GREEN)**:
  - [ ] RED: `generateIdentity()` twice → distinct pubkeys; `sign`/verify round-trips against
        `crypto.verify`.
  - [ ] RED: `computeEnrollFpr` is deterministic + matches the §4.2 `enroll_fpr` derivation P4/P6 pin.
  - [ ] RED: `saveIdentity` then read the file mode → **`0600`** (INV5); a world-readable dir triggers
        a hard error, not a silent write.
  - [ ] RED (security): **assert there is no code path that serializes the private key into any network
        payload** — `AgentIdentity` exposes only `publicKey`, `enrollFpr`, `sign` (INV4). Grep test:
        no export/return of raw private-key bytes.
  - [ ] GREEN.
- **Test cases**: keygen uniqueness; sign/verify; fpr determinism + cross-checks fixture P4/P6 will pin;
  keystore perms `0600`; reload identity yields same pubkey/fpr; corrupt key file → typed error;
  **negative**: no API returns/serializes the private key; **FIX 3**: `saveContentSecret`/`loadContentSecret`
  round-trip the `hostContentSecret` bytes, file mode `0600`, and the secret never appears in any log line.
- **Security**: **INV4** (private key never leaves host — this is the load-bearing task), **INV5** (0600 at
  rest — key/cert **and** the FIX 3 `hostContentSecret`). Cross-check `enroll_fpr` derivation with P4 (§4.4
  TOFU pin) and P3 (§4.2 storage).

---

### W1 · Enrollment (v0.8 skeleton → v0.9 real Ed25519)

#### T4 · Pairing redemption + CSR `[ ]` — v0.9 (§4.5) — v0.8 ships a token-gate variant
- **Owns**: `agent/src/enroll/pair.ts`, `agent/src/enroll/csr.ts`,
  `agent/test/pair.test.ts`, `agent/test/csr.test.ts`
- **Depends**: T3 (identity), T2 (config); **cross-plan**: P3 `/enroll` endpoint (§4.5 BIND/RETURN) —
  mocked in unit tests. **Parallel-safe**: with T5/T6.
- **Contracts** — implements INDEX **§4.5** steps 2–5 verbatim (agent side), validating the response with
  the **frozen pairing Zod schema** from `relay-contracts/`:
  ```ts
  // §4.5 step 3 REDEEM: POST /enroll { code, agentPubkey, csr }   (raw code NEVER persisted — INV5)
  // §4.5 step 5 RETURN → the FROZEN `EnrollResult` — IMPORTED from relay-contracts, NOT redeclared here (FIX 3).
  // Frozen shape (INDEX §4.5): { hostId, subdomain, cert, caChain, hostContentSecret } — the last field is
  // the FIX 3 host-scoped secret, WRAPPED to this agent's enrolled Ed25519 identity (agent_pubkey) by P3 at BIND.
  import type { EnrollResult } from 'relay-contracts'
  export function buildCsr(id: AgentIdentity, subject: string): string   // PKCS#10 over the Ed25519 key
  export function redeemPairingCode(
    enrollUrl: string, code: string, id: AgentIdentity, ks: Keystore, fetchImpl?: typeof fetch,
  ): Promise<EnrollResult>
  // On success (FIX 3): UNWRAP EnrollResult.hostContentSecret with the enrollment private key (in-process, INV4)
  // and persist the UNWRAPPED secret 0600 via ks.saveContentSecret (T3) — the WRAPPED bytes are never stored,
  // the unwrapped secret is never logged or re-sent (INV5/INV9). Feeds §4.4 deriveContentKey (T19).
  ```
- **TDD (RED→GREEN)**:
  - [ ] RED: `redeemPairingCode` sends `{ code, agentPubkey, csr }`; assert the **private key is NOT in
        the request body** (INV4) — only pubkey + CSR.
  - [ ] RED: raw `code` is **never written to `stateDir`** and never logged (INV5/INV9).
  - [ ] RED: malformed/HTTP-error `/enroll` response → typed `EnrollError` (no swallow); response not
        matching the frozen §4.5 schema → reject (boundary validation).
  - [ ] RED (single-use): a `409 already-redeemed` from P3 → surfaced as `PairingCodeSpentError`
        (agent must not retry-spam a spent code).
  - [ ] RED (**FIX 3**): `EnrollResult.hostContentSecret` (wrapped to the agent identity) is UNWRAPPED with
        the enrollment private key and stored `0600` via `Keystore.saveContentSecret` (T3); assert the
        **wrapped** bytes are never persisted and the unwrapped secret is never logged (INV5/INV9).
  - [ ] GREEN.
- **Test cases**: happy path returns the frozen §4.5 shape `{hostId, subdomain, cert, caChain,
  hostContentSecret}` and keystore stores cert `0600` **and the unwrapped `hostContentSecret` `0600`**; body
  omits private key; expired-code (`410`) → `PairingCodeExpiredError`; spent-code (`409`) →
  `PairingCodeSpentError`; schema-mismatch response → rejected; `enrollUrl` non-https → refused (T2 guard).
  **v0.8 variant**: when no Ed25519 flow is enabled, `redeemPairingCode` degrades to presenting the shared
  `agentToken` (still hashed server-side by P3) — behind an explicit `EnrollMode = 'token' | 'ed25519'`
  string-literal union; a test asserts `'ed25519'` is the default from v0.9.
- **Security**: **INV4** (only pubkey+CSR leave), **INV5** (raw code not at rest), boundary validation on
  the enroll response (untrusted external data), single-use respect.

#### T5 · CLI entrypoint `[ ]` — v0.8 skeleton → extended each wave
- **Owns**: `agent/src/cli.ts`, `agent/test/cli.test.ts`
- **Depends**: T2, T3, T4 (and later T7/T13/T17 handlers). **Parallel-safe**: with T4/T6.
- **Contracts**:
  ```ts
  export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
  export interface CliArgs { readonly command: CliCommand; readonly code?: string; readonly flags: Readonly<Record<string, string | boolean>> }
  export function parseArgs(argv: readonly string[]): CliArgs
  export function runCli(args: CliArgs, deps: CliDeps): Promise<number>   // returns process exit code
  ```
  `runCli` dispatches: `pair <code>` → T4 redeem → keystore + config write → optional T17 install;
  `run` → T7 tunnel loop; `status` → read keystore/config, print host_id/subdomain/online (no secrets);
  `install`/`uninstall` → T17. Dependencies injected (`CliDeps`) so tests avoid real network/FS.
- **TDD**: RED parse `pair ABCD-1234` → `{command:'pair', code:'ABCD-1234'}`; unknown command → nonzero
  exit + typed error (no throw to bare stack); `status` prints **no** key/cert material (INV9); missing
  `code` on `pair` → usage error. GREEN.
- **Test cases**: arg parsing matrix; `pair` happy path calls redeem then install (mocked); `status`
  redaction; unknown command exit code; `run` before pairing → "not enrolled" error (fail-fast).
- **Security**: INV9 (status shows no secrets); fail-fast when unenrolled.

#### T6 · frp scaffold (v0.8 stepping-stone) `[ ]` — v0.8 ONLY (retired at v0.9)
- **Owns**: `agent/src/transport/frpScaffold.ts`, `agent/test/frpScaffold.test.ts`
- **Depends**: T2, T5. **Parallel-safe**: with T4/T5.
- **Rationale (EXPLORE §5 "wrap `frpc` as the agent", INDEX §1 v0.8)**: fastest path to the café demo;
  wraps `frpc` (child process) presenting the shared `agentToken`, registering `subdomain`, forwarding to
  `127.0.0.1:3000`. **Explicitly a stepping-stone** — the native mux (T7–T11) replaces it in v0.9; the
  §4.1 frame path is designed so ciphertext (T15) drops in without reshaping.
- **Contracts**:
  ```ts
  export interface FrpScaffold { start(): Promise<void>; stop(): Promise<void>; onExit(cb: (code: number) => void): void }
  export function spawnFrpc(cfg: AgentConfig, frpcPath: string): FrpScaffold   // generates frpc.toml, spawns child
  ```
- **TDD**: RED generated `frpc.toml` has `local_ip=127.0.0.1 local_port=3000`, `subdomain=<cfg>`, tls
  enabled; child crash → `onExit` fires and (paired with T10) triggers backoff. GREEN with a mocked
  spawn.
- **Test cases**: toml generation; child-exit propagation; **negative**: never forwards to a non-loopback
  `local_ip`. **Retirement test**: a v0.9 guard test asserts `frpScaffold` is not imported by `run`
  once `EnrollMode==='ed25519'` (prevents shipping both substrates).
- **Security**: loopback-only forward; the shared token is a **v0.8-only** shortcut (INDEX §1 — "must reach
  real accounts before the first paid signup").

---

### W2 · Native mux tunnel (v0.9) — consumes P1 §4.1

#### T7 · Tunnel holder `[ ]` — v0.9
- **Owns**: `agent/src/transport/tunnel.ts`, `agent/test/tunnel.test.ts`
- **Depends**: T2 (W0 `seams.ts` `WsLike`), T3, dial (T12) at integration; **cross-plan**: P1 §4.1 codec
  (`encodeMuxFrame`/`decodeMuxFrame`) from `relay-contracts/`. **Parallel-safe**: with T8–T11 (disjoint
  files, shared frozen §4.1 interface).
- **Contracts** (imports §4.1 `MuxFrameHeader`, `MuxFrameType`, codec — **never** re-declared; imports
  `WsLike` + the frozen `GoAwayReason` from `relay-contracts/`):
  ```ts
  import { WsLike } from './seams'   // W0 shared seam (T2)
  import { GoAwayReason } from 'relay-contracts'   // FROZEN §4.1 3-value union: 'operatorDrain'|'revoked'|'shutdown'
  export interface Tunnel {
    send(header: MuxFrameHeader, payload: Uint8Array): void   // encodeMuxFrame → socket
    onFrame(cb: (h: MuxFrameHeader, payload: Uint8Array) => void): void
    goAway(lastStreamId: number, reason: GoAwayReason): void  // §4.1 GOAWAY — reason is the FROZEN union, not a raw int
    close(): void
  }
  export function holdTunnel(socket: WsLike, router: StreamRouter, heartbeat: Heartbeat): Tunnel
  ```
  The demux loop: `decodeMuxFrame` each inbound frame → dispatch by `type` to `router` (OPEN/DATA/
  CLOSE/RST) or `heartbeat` (PING/PONG) or connection-level (GOAWAY/WINDOW_UPDATE streamId 0). **Illegal
  transitions** (DATA before OPEN, DATA after CLOSE, unknown streamId) ⇒ **RST that stream, never the
  tunnel** (§4.1 stream lifecycle).
- **TDD (RED→GREEN)**:
  - [ ] RED: feed a byte-exact §4.1 frame fixture → `onFrame` yields the decoded header+payload.
  - [ ] RED: DATA on an unknown `streamId` → agent emits a `CLOSE` with `flags.RST` for that stream and
        the **tunnel stays up** (§4.1).
  - [ ] RED: **INV11** — tunnel imports no ANSI/xterm parser; DATA payload passes through opaque.
  - [ ] RED: a `GOAWAY` frame → in-flight streams allowed to finish, no new OPEN accepted (drain).
  - [ ] GREEN.
- **Test cases**: round-trip encode→decode using the §4.1 fixture; RST-on-illegal-transition (DATA
  before OPEN, DATA after CLOSE, dup streamId); GOAWAY drain; oversize `payloadLen > maxFrameBytes` →
  frame rejected (boundary guard, safe-int); opaque-passthrough (INV11).
- **Security**: INV11 (opaque), robust framing (malformed frames can't crash the tunnel or bleed streams).

#### T8 · Stream router + loopback forwarder `[ ]` — v0.9
- **Owns**: `agent/src/transport/streamRouter.ts`, `agent/src/transport/loopback.ts`,
  `agent/test/streamRouter.test.ts`, `agent/test/loopback.test.ts`
- **Depends**: T7; T2 (loopback target). **Parallel-safe**: with T9–T11.
- **Contracts** (validates `MuxOpen` with the **frozen §4.1 Zod schema** from `relay-contracts/`):
  ```ts
  export interface StreamRouter {
    handleOpen(open: MuxOpen): void      // one OPEN ⇒ one loopback socket — AFTER the subdomain boundary check
    handleData(streamId: number, payload: Uint8Array): void
    handleClose(streamId: number, rst: boolean): void
    activeStreamCount(): number
  }
  export function createStreamRouter(cfg: AgentConfig, tunnel: Tunnel, dialLoopback: DialLoopback, transform: FrameTransform): StreamRouter
  export type DialLoopback = (path: string, origin: string) => Promise<WsLike>
  // dials ws://127.0.0.1:3000<path>, sets header `Origin: <origin>` from §4.1 MuxOpen.originHeader
  export function dialLoopback(target: string): DialLoopback
  // FrameTransform: identity in v0.9 (plaintext passthrough); replaced by the E2E codec in v0.10 (T15)
  export interface FrameTransform {
    inbound(streamId: number, cipher: Uint8Array): Uint8Array | null   // tunnel→local (decrypt in v0.10)
    outbound(streamId: number, plain: Uint8Array): Uint8Array          // local→tunnel (encrypt in v0.10)
    openStream(streamId: number): void; closeStream(streamId: number): void
  }
  export const identityTransform: FrameTransform   // v0.9: inbound/outbound = passthrough
  ```
  **Per-stream state is a fresh allocation** — **no global mutable buffers** (EXPLORE §4b failure-mode #3;
  cross-tenant buffer bleed is impossible because each stream owns its socket + transform state). Closing a
  stream (CLOSE/RST) frees the paired loopback socket (§4.1).

  **MANDATORY subdomain boundary check (defense-in-depth against a compromised/buggy/misconfigured relay).**
  Per §4.1, authorization is the relay's job (P5) and there is exactly **one physical tunnel per host**, so a
  correctly-functioning relay never delivers a foreign-tenant `OPEN` here. But the whole premise is *don't
  fully trust the relay* — the agent is the last place that can catch a cross-wired frame. Therefore, the
  **first** thing `handleOpen` does — before allocating any state or dialing loopback — is compare the frozen
  `MuxOpen.subdomain` (§4.1) against this agent's own enrolled `AgentConfig.subdomain` (§4.2, filled at
  pairing):

  ```ts
  // handleOpen — first statement, before any allocation or dial:
  if (open.subdomain !== cfg.subdomain) {
    // never proceed to dial; RST this stream and audit-log the mismatch (metadata only, INV10 — no payload)
    tunnel.send(rstHeaderFor(open.streamId), EMPTY)   // §4.1 CLOSE + flags.RST
    logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId })
    return
  }
  ```

  This is a one-field comparison; it closes an otherwise-silent cross-tenant-bleed vector (host B's OPEN
  spliced into host A's tunnel) at the single point in the whole system where the agent can detect it. It
  **complements, never replaces**, relay-side authz (INV1, owned by P1/P5) — belt-and-suspenders.
- **TDD (RED→GREEN)**:
  - [ ] RED: `handleOpen` dials `ws://127.0.0.1:3000` at `MuxOpen.requestPath` **replaying
        `MuxOpen.originHeader`** as the `Origin` header (so the UNCHANGED base app's Origin check passes
        against `ALLOWED_ORIGINS` — EXPLORE §3).
  - [ ] RED: loopback output bytes → `transform.outbound` → `tunnel.send(DATA)`; tunnel DATA →
        `transform.inbound` → loopback write (v0.9 passthrough).
  - [ ] RED: CLOSE/RST frees the loopback socket; loopback close → agent sends CLOSE upstream.
  - [ ] RED (**INV5/isolation**): two concurrent streams get **separate** socket + buffer objects; a
        marker written on stream A never appears on stream B's socket (per-connection allocation).
  - [ ] RED (**INV1 defense-in-depth / cross-tenant boundary**): an `OPEN` whose `MuxOpen.subdomain !==
        cfg.subdomain` is **RST immediately and NEVER dialed** — assert `dialLoopback` is not called, the
        stream is RST, and a metadata-only mismatch is logged (INV10). This is the load-bearing negative
        test: it must fail if a future refactor drops the guard.
  - [ ] RED: `MuxOpen` failing the frozen §4.1 schema → stream RST, not a crash.
  - [ ] GREEN.
- **Test cases**: OPEN→dial with correct path+Origin; bidirectional splice; CLOSE frees socket; RST on
  invalid MuxOpen; **OPEN for a foreign subdomain is RST, never dialed** (cross-tenant boundary — assert no
  loopback dial); **two-stream isolation** (no cross-stream bleed); loopback connect-refused (base app
  down) → RST upstream + typed error (no swallow); backpressure hook calls into T11.
- **Security**: **INV11** (opaque splice), **INV5** (per-connection allocation, no global buffers),
  **INV1 defense-in-depth** (subdomain-mismatch OPEN is RST before dial — the agent's own last-line check on
  a cross-wired relay frame; complements, never replaces, relay-side authz owned by P1/P5),
  Origin replay preserves CSWSH protection end-to-end (EXPLORE §3 "do not weaken the check").

#### T9 · Heartbeat `[ ]` — v0.9
- **Owns**: `agent/src/transport/heartbeat.ts`, `agent/test/heartbeat.test.ts`
- **Depends**: T7. **Parallel-safe**: with T8/T10/T11.
- **Contracts** (§4.1 PING/PONG, streamId 0, **15s** — cited verbatim):
  ```ts
  export interface Heartbeat { onPing(token: Uint8Array): void; start(): void; stop(): void; onDead(cb: () => void): void }
  export function createHeartbeat(tunnel: Tunnel, opts?: { intervalMs?: number; timer?: TimerLike }): Heartbeat
  export const HEARTBEAT_INTERVAL_MS = 15_000   // §4.1: "heartbeat every 15s, miss ⇒ tunnel dead"
  ```
- **TDD** (fake timers): RED — inbound PING → agent replies PONG echoing the 8-byte token; no PONG within
  interval → `onDead` fires (⇒ T10 reconnect). GREEN.
- **Test cases**: PING→PONG echo; missed PONG → dead after `HEARTBEAT_INTERVAL_MS`; `stop()` cancels timer
  (no leak); token echoed byte-exact.
- **Security**: liveness only; no secret material.

#### T10 · Reconnection / backoff `[ ]` — v0.9
- **Owns**: `agent/src/transport/backoff.ts`, `agent/test/backoff.test.ts`
- **Depends**: T2 (dial trigger **and** the W0 `seams.ts` `isRevoked` seam). **Does NOT depend on T14** —
  `reconnectLoop` only *consumes* an injected `isRevoked: () => boolean` callback (the seam type is frozen at
  W0 in `seams.ts`), so there is **no task-level cycle** with T14. T14 *implements* `RevocationState` and
  wires its `isRevoked` into this loop at integration; T10 never imports T14. **Parallel-safe**: with
  T8/T9/T11 (and with T14 — no edge either way).
- **Contracts** — **reuses the base app's 1/2/4…cap-30s** policy (EXPLORE §3 "the *same policy the frontend
  already ships*"):
  ```ts
  export interface BackoffPolicy { nextDelayMs(): number; reset(): void }
  export function createBackoff(opts?: { baseMs?: number; capMs?: number; jitter?: boolean }): BackoffPolicy
  export const BACKOFF_BASE_MS = 1_000
  export const BACKOFF_CAP_MS = 30_000     // 1s/2s/4s…cap 30s
  export function reconnectLoop(dial: () => Promise<Tunnel>, backoff: BackoffPolicy, isRevoked: () => boolean): Promise<void>
  ```
- **TDD** (fake timers): RED — delays follow 1s,2s,4s,8s,16s,30s(cap),30s…; `reset()` after a successful
  connect returns to 1s; **`isRevoked()===true` short-circuits the loop (no reconnect)** — INV12. GREEN.
- **Test cases**: exact backoff sequence + cap; jitter within bounds when enabled; reset-on-success;
  **revoked host does not reconnect** (INV12); dial failure loops, dial success resolves.
- **Security**: **INV12** (a revoked host must stop trying, not thrash the relay).

#### T11 · Per-stream flow control `[ ]` — v0.9
- **Owns**: `agent/src/transport/flowControl.ts`, `agent/test/flowControl.test.ts`
- **Depends**: T7, T8. **Parallel-safe**: with T8–T10 (own file).
- **Contracts** — **consumes** the §4.1 `WINDOW_UPDATE` credit protocol (P1 owns the protocol; the agent
  is a credit-respecting sender/receiver so one heavy `vim`/`top` redraw can't starve another stream):
  ```ts
  export interface FlowController {
    consume(streamId: number, bytes: number): boolean   // false ⇒ credit exhausted, pause this stream
    grant(streamId: number, credit: number): void       // on inbound WINDOW_UPDATE
    initWindow(streamId: number, initialCredit: number): void
  }
  export function createFlowController(): FlowController
  ```
- **TDD**: RED — sending past the initial window returns `false` (pause); a `WINDOW_UPDATE grant` resumes;
  credit is **per-stream** (exhausting A does not pause B). GREEN.
- **Test cases**: pause at zero credit; resume on grant; per-stream independence (starvation guard);
  connection-level (streamId 0) credit applies to the whole link (§4.1).
- **Security**: DoS resilience (per-stream fairness); no cross-stream state bleed.

---

### W3 · mTLS + lifecycle (v0.9 / v0.10)

#### T12 · Outbound mTLS dial `[ ]` — v0.9
- **Owns**: `agent/src/transport/dial.ts`, `agent/test/dial.test.ts`
- **Depends**: T3 (key), T4 (cert), T2 (relayUrl); **cross-plan**: P3 CA (validates server cert),
  P1 `/agent` endpoint (mocked in unit tests). **Parallel-safe**: with T13/T14.
- **Contracts** (INV14 — mTLS, **no bearer token on the tunnel**, EXPLORE §4a.1):
  ```ts
  import { WsLike } from './seams'   // W0 shared seam (T2) — NOT redeclared here
  export function dialRelay(cfg: AgentConfig, ks: Keystore): Promise<WsLike>
  // builds a wss:// client with { cert, key(in-process), ca: caChain }; rejectUnauthorized: true
  ```
- **TDD (RED→GREEN)**:
  - [ ] RED: dial constructs a TLS client with the **client cert + in-process private key + CA chain**;
        `rejectUnauthorized` is **true** (never disabled — a common insecure default).
  - [ ] RED: **no bearer/agent token** is placed in a header or the URL (INV4 — mTLS is the auth).
  - [ ] RED: absent/expired cert in keystore → dial refused with `NotEnrolledError`/`CertExpiredError`
        (fail-fast, INV14).
  - [ ] RED: a relay server cert not chaining to the pinned CA → handshake rejected (no MITM).
  - [ ] GREEN (mock TLS layer).
- **Test cases**: cert+key+CA wired; `rejectUnauthorized` true; no token header; missing cert →
  NotEnrolled; expired cert → refuse; bad server chain → reject.
- **Security**: **INV14** (mTLS), **INV4** (asymmetric, no shared secret on the wire), pinned CA (anti-MITM).

#### T13 · Short-lived cert rotation `[ ]` — v0.9 → hardened v0.10
- **Owns**: `agent/src/certs/rotation.ts`, `agent/test/rotation.test.ts`
- **Depends**: T3, T4, T12; **cross-plan**: P3 renewal endpoint (SPIFFE-style, §4.5 signs only pubkeys in
  the registry). **Parallel-safe**: with T14.
- **Contracts** (INV14 — auto-rotating, seamless):
  ```ts
  export interface CertRotator { start(): void; stop(): void; onRotated(cb: () => void): void; onRevoked(cb: () => void): void }
  export function createCertRotator(cfg: AgentConfig, id: AgentIdentity, ks: Keystore, opts?: { renewBeforeMs?: number; timer?: TimerLike }): CertRotator
  // renews via P3 using a fresh CSR (T4) over the SAME Ed25519 key; installs new cert atomically (INV8-style swap)
  ```
- **TDD** (fake timers): RED — timer fires at `expiry - renewBeforeMs`, posts a fresh CSR, installs the
  new cert **atomically** (old cert never partially overwritten); a `403 revoked` from the renewal
  endpoint → `onRevoked` fires (⇒ T14 teardown, INV12); rotation mid-tunnel does **not** drop live
  streams (seamless). GREEN.
- **Test cases**: renew-before-expiry timing; atomic cert swap (no torn read of cert file); renewal
  refusal (revoked) → onRevoked; renewal uses the same key (pubkey unchanged, only cert rotates);
  network error on renew → retry with backoff, tunnel stays up until cert actually expires.
- **Security**: **INV14** (short-lived auto-rotation; revocation = "just stop renewing" per EXPLORE §4a.1),
  **INV12** (renewal-refusal path), **INV8** (atomic swap of cert-at-rest).

#### T14 · Revocation + GOAWAY teardown `[ ]` — v0.9 (drain) / v0.10 (revoke)
- **Owns**: `agent/src/lifecycle/revocation.ts`, `agent/test/revocation.test.ts`
- **Depends**: T7 (tunnel), T13 (renewal-refusal), **T2 (the W0 `seams.ts` `RevocationState`/`RevokeReason`
  seam)**; **cross-plan**: the **frozen INDEX §4.1 3-value `GoAwayReason`** union + `decodeGoAwayReason` in
  `relay-contracts/` (§4.1 line 225 — RESOLVED, no longer an open question). T14 **wires** T10's
  `reconnectLoop` (injects its `isRevoked` into the loop) but does **not** depend on T10 as a task — the
  edge is one-way (T14→consumes T10's function via the W0 seam type), so there is **no cycle**.
  **Parallel-safe**: with T12/T13.
- **Contracts** (INV12 — fast revocation kills live tunnels in seconds). The `RevocationState`/`RevokeReason`
  seam is **imported from `agent/src/transport/seams.ts` (T2), not redeclared here**; the GOAWAY reason
  mapping is **imported from `relay-contracts/`, never invented locally**:
  ```ts
  import { RevocationState, RevokeReason } from '../transport/seams'   // W0 seam (T2)
  import { GoAwayReason, decodeGoAwayReason } from 'relay-contracts'   // FROZEN §4.1 3-value union + decoder (see below)
  export function createRevocationState(onTeardown: () => void): RevocationState
  // markRevoked ⇒ close all streams + tunnel immediately AND make T10's isRevoked() true (no reconnect)
  // classifyGoAway maps the FROZEN 3-value wire reason to the agent's action:
  //   'operatorDrain' → 'drain' · 'shutdown' → 'drain' · 'revoked' → 'revoked'  (an unknown numeric code never
  //   reaches here — decodeGoAwayReason throws, and T14 fails closed to 'revoked'; see below).
  export function classifyGoAway(reason: GoAwayReason): 'drain' | 'revoked'   // pure exhaustive map over the FROZEN 3-value union
  ```
  **Finding-3 fix (RESOLVED) — GOAWAY reason semantics are a FROZEN shared contract, not a local numeric guess.**
  INDEX §4.1 (line 225) now freezes `GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'` (string-literal
  union) + `decodeGoAwayReason` (`1→operatorDrain · 2→revoked · 3→shutdown`, else throws) — the **same** codes
  P1 `drain.ts` `DRAIN_REASON` (`operatorDrain:1, revoked:2, shutdown:3`) and P5 revocation (FIX 4) emit. T14
  imports this union + decoder **verbatim** from `relay-contracts/` and **must not** hard-code reason integers
  or redeclare the union locally. This closes the earlier sender/receiver drift where P1 emitted
  `operatorDrain`/`shutdown` while P2 assumed a 2-value `'drain' | 'revoked'` union the frozen decoder never
  emits — a genuine **revoke** misread as a **drain** would have let the agent *reconnect elsewhere* instead of
  *refusing to reconnect*, silently defeating INV12 with **both plans' unit tests still green**. `classifyGoAway`
  is a pure exhaustive map over the frozen 3-value union: the graceful reasons **`operatorDrain` and `shutdown`
  both → `'drain'`** (finish in-flight, reconnect elsewhere — EXPLORE §3 node drain/shutdown) and **`revoked` →
  `'revoked'`** (immediate teardown, no reconnect). An **unknown numeric code** never yields a valid
  `GoAwayReason` — `decodeGoAwayReason` throws — and T14 **fails closed: treats it as `'revoked'`** (a default
  that guesses "drain" is exactly the INV12-defeating failure). No stub/default mapping is permitted.
- **TDD**: RED — a §4.1 `GOAWAY` decoding (via `decodeGoAwayReason`) to `'revoked'` → **immediate** teardown
  of all streams + tunnel and `isRevoked()` becomes true so T10 does **not** reconnect; an `'operatorDrain'`
  **or** `'shutdown'` GOAWAY → graceful (finish in-flight, reconnect elsewhere); an **unknown/unmapped** reason
  code (`decodeGoAwayReason` throws) → **fail-closed: treated as `'revoked'`** (never silently as drain) +
  typed error surfaced (no swallow). GREEN.
- **Test cases**: revoke → teardown within one tick (seconds-scale, INV12); the three frozen reasons map
  correctly — **`operatorDrain`→drain, `shutdown`→drain, `revoked`→revoke** — distinguished **only** via the
  frozen `GoAwayReason`/`decodeGoAwayReason` (a test asserts no bare integer literal for a reason appears in
  `revocation.ts`, and that `classifyGoAway` is exhaustive over the 3-value union); **unknown reason code fails
  closed to revoked** (`decodeGoAwayReason` throws, INV12 safety); post-revoke reconnect suppressed (asserts
  the injected `isRevoked` returns true to T10's loop); operator-drain path reconnects elsewhere.
- **Security**: **INV12** (global + per-host revocation the agent honors within seconds; a revoked host
  neither holds a tunnel nor reconnects; unknown reason codes fail closed to revoked). Distinguish
  node-drain/shutdown (`operatorDrain`/`shutdown` → reconnect elsewhere, EXPLORE §3) from revoke (`revoked` →
  stop) **only** through the frozen §4.1 3-value reason contract — never a locally-chosen mapping (cross-plan
  drift = silent INV12 defeat).

---

### W4 · Agent-side E2E endpoint (v0.10) — consumes P4 `relay-e2e/`

#### T15 · Host E2E endpoint (`host_hello` + seal/open splice) `[ ]` — v0.10
- **Owns**: `agent/src/e2e/hostEndpoint.ts`, `agent/test/hostEndpoint.test.ts`
- **Depends**: T3 (identity, for the transcript `sig`), T8 (`FrameTransform` seam), T19 (`ReplaySealer`,
  same wave — authored before T15 wires it); **cross-plan HARD PRE-W4 GATE** — **P4 `relay-e2e/`** must FIRST
  freeze BOTH (a) `sealFrame`/`openFrame` + the `createHostHandshake` host-handshake wiring AND (b) the
  **injected device-auth-proof verifier** — P5-owned `verifyDeviceProof(proof, { clientEphPub, clientNonce })`
  reaching T15 **through** P4's `createHostHandshake` (FIX 6b, **not** a direct `relay-e2e` import) — **plus a
  published cross-plan test vector** (a valid proof + at least one forged/failing proof). Browser side is P6.
  **Parallel-safe**: standalone file — **but see the blocking gate below: T15 does not START coding the
  proof-abort path until (b) lands.**

  > **BLOCKING GATE (was §6 open Q#2 — now a hard, named pre-W4 gate). The anti-MITM guarantee of the
  > entire authenticated-ECDH-through-the-relay design rests on `deviceAuthProof` verification (§4.4
  > `ClientHello`).** T15 only *consumes* that verifier; its issuance+verification lives in **P5** and reaches
  > T15 **injected through P4's `createHostHandshake`** (FIX 6b) — the agent never `import`s it from
  > `relay-e2e`. Until P5's `verifyDeviceProof` (bound to `{ clientEphPub, clientNonce }`) is frozen, wired
  > into P4's `createHostHandshake`, **and** the shared test vector is published, there is no definition of
  > what "the proof fails" means. **T15 is BLOCKED, NOT DEGRADED**: it must **not** merge or ship against a
  > stubbed / no-op / always-true proof check to unblock the wave. A stubbed verifier would let a relay that
  > injects a forged `ClientHello` complete a full handshake and derive `DirectionalKeys` with the agent —
  > **exactly the threat this locked decision defends against** — silently defeating E2E. If the gate is not
  > yet met when T15 is dispatched, the builder returns `[!] BLOCKED` (never guesses a proof format). The
  > orchestrator resolves it by landing P5's verifier (via P4's wiring) + the vector, then re-dispatches.

- **Contracts** — implements the **host side** of INDEX **§4.4** and plugs a real `FrameTransform` into T8
  (replacing `identityTransform`). It **imports** P4's crypto **and drives P4's host-handshake wiring** (the
  device-auth-proof verifier reaches it **injected**, FIX 6b); it **re-declares nothing** from §4.4:
  ```ts
  // FIX 6b — verifier is P5-owned and injected via P4's createHostHandshake; NEVER `import { verifyDeviceAuthProof } from 'relay-e2e'`.
  import { sealFrame, openFrame, createHostHandshake } from 'relay-e2e'   // P4 — frozen §4.4 primitives + host-handshake wiring
  import { createE2ESession } from 'relay-contracts'
  import type {
    ClientHello, HostHello, HandshakeResult, DirectionalKeys, E2ESession, AeadKey,
  } from 'relay-contracts'   // §4.4 FROZEN shapes — cited verbatim, no local redefinition
  // Host-side device-proof verifier — INJECTED (P5 issues+verifies; P4 wires it), bound to { clientEphPub, clientNonce } (FIX 6b):
  export type VerifyDeviceProof =
    (proof: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }) => Promise<boolean>
  // Host side of §4.4: receive ClientHello (relay DATA) → verify proof FIRST → reply HostHello → derive DirectionalKeys.
  // MITM defense: makeHostHello drives createHostHandshake({ verifyDeviceProof, identity: id }), which calls the injected
  // verifier bound to { clientEphPub, clientNonce } FIRST and ABORTS (throws MitmAbortError, derives NO keys) on failure —
  // there is no code path that skips this call.
  export function makeHostHello(
    clientHello: ClientHello, id: AgentIdentity, verifyDeviceProof: VerifyDeviceProof,
  ): Promise<{ hello: HostHello; result: HandshakeResult }>
  // FIX 2 — returns HandshakeResult{ keys: DirectionalKeys{c2h,h2c}, aead, transcript }; NO single sessionKey/CryptoKey.
  // HostHello.sig = id.sign(transcript); HostHello.enrollFpr = id.enrollFpr (§4.4 — browser pins it, TOFU)
  export function createE2ETransform(
    id: AgentIdentity, verifyDeviceProof: VerifyDeviceProof, replay: ReplaySealer,   // replay = T19 K_content sealer (FIX 3)
  ): FrameTransform
  // per stream: absorb ClientHello → (verify proof → else abort) → emit HostHello →
  //   session = createE2ESession('host', result)   // host seals with h2c, opens with c2h (§4.4)
  // thereafter: inbound = session.open(dataPayload); outbound(LIVE) = session.seal(plain)  [ephemeral h2c];
  //   AND every replay-bound output is ALSO sealed via replay.seal(plain)  [recoverable K_content, T19] — DISTINCT from live frames (FIX 3).
  // Synchronous AeadKey seal/open; monotonic seq per direction (§4.4 / INV13).
  ```
  **Boundary**: after `openFrame` decrypts, the bytes are handed to the loopback WS **as opaque bytes** —
  the agent still **parses no terminal semantics** (INV11). Plaintext exists only host-local.
- **TDD (RED→GREEN)**:
  - [ ] RED: `makeHostHello` produces a `HostHello` whose `sig` verifies under `enrollFpr` and whose ECDH
        yields a `HandshakeResult` whose **`keys: DirectionalKeys{ c2h, h2c }`** match what the P4 client-side
        test vector computes (§4.4 — direction-split, **no single `sessionKey`/`CryptoKey`**).
  - [ ] RED (**anti-MITM, gated on the frozen verifier + vector**): using **P4's published forged-proof
        vector**, a `ClientHello` whose `deviceAuthProof` fails the injected `verifyDeviceProof` → **ABORT**
        (`MitmAbortError`, **no `DirectionalKeys` derived, no `HostHello` emitted**); likewise a transcript
        mismatch → ABORT. This test is written against P4's real vector, **not** a local stand-in; it is the
        load-bearing MITM test.
  - [ ] RED (**no-stub CI guard, INV2 anti-MITM**): a static/CI guard fails the build if `makeHostHello`/
        `createE2ETransform` can reach key derivation **without** calling the real (non-stub) injected
        `verifyDeviceProof` — e.g. assert `hostEndpoint.ts` does **NOT** import any verifier from `relay-e2e`
        (the verifier arrives only via the `verifyDeviceProof` param wired through P4's `createHostHandshake`,
        FIX 6b), and a mutation test that replaces the injected verifier with `async () => true` makes the MITM
        test FAIL. This makes "shipped against a stubbed proof check" an automatic red build.
  - [ ] RED (**INV2**): given a stream's DATA frames, assert the **relay-visible** payload is the §4.4
        `E2EEnvelope` (ciphertext+nonce+tag+seq) — a known plaintext marker fed at the loopback never
        appears in any DATA payload bytes.
  - [ ] RED (**INV13**): `openFrame` rejects a **replayed** envelope (dup seq), a **reordered** seq, and a
        **bit-flipped** ciphertext (AEAD tag fail) → the stream is torn down, not silently dropped-through.
  - [ ] RED (**INV11**): even post-decrypt, no ANSI/xterm import; decrypted bytes forwarded opaque.
  - [ ] GREEN by wiring P4 primitives.
- **Acceptance criteria (explicit — mirrored in T18):**
  1. **T15 is BLOCKED, not degraded, until the proof-verification contract exists** — no merge against a
     stub/no-op/always-true `verifyDeviceProof` (P5-owned, injected via P4's `createHostHandshake`, FIX 6b);
     the forged-proof vector from P4 must be present and the abort test green.
  2. The no-stub CI guard (mutation test above) is present and passing.
  3. All INV2/INV13/INV11 tests above green.
- **Test cases**: handshake key-agreement (**`DirectionalKeys{c2h,h2c}`**) vs P4 vector; **forged-proof →
  ABORT (no keys) using P4's forged vector**; **no-stub CI guard (verifier-mutation → MITM test fails)**;
  envelope-only on the wire (INV2); replay/reorder/tamper rejection (INV13); multi-device — two independent
  `ClientHello`s derive two independent `HandshakeResult`s over the authenticated channel (§4.4,
  INV3-adjacent); passthrough remains opaque (INV11).
- **Security**: **INV2** (relay sees only ciphertext), **INV13** (per-message nonce + monotonic seq),
  **INV11** (opaque even after decrypt), **enrollment-key-bound + device-auth-proof-gated handshake**
  (anti-MITM by a malicious relay — the guarantee is only real once the injected `verifyDeviceProof` is P5's
  real verifier wired via P4's `createHostHandshake`, never a stub; enforced by the no-stub CI guard).

#### T19 · Replay-frame sealer (recoverable `K_content`) `[ ]` — v0.10 (FIX 3)
- **Owns**: `agent/src/e2e/replaySeal.ts`, `agent/test/replaySeal.test.ts`
- **Depends**: T3 (`Keystore.loadContentSecret`), T2 (config); **cross-plan**: `relay-contracts/` §4.4 replay
  surface (`ReplayKeyParams`, `deriveContentKey`, `sealReplayFrame`, `REPLAY_KDF_INFO`) + P4 `relay-e2e/`
  impls. **Wired INTO T15's `createE2ETransform`** (same wave — authored before T15 consumes it).
  **Parallel-safe**: standalone file (no edge back to T15).
- **Rationale (FIX 3 — recoverable replay under E2E).** Live host→client frames are sealed with the
  **ephemeral** `DirectionalKeys.h2c` (forward-secret — gone after reconnect). But the "refresh the page and
  the Claude session is still there" guarantee (EXPLORE §4c) needs the ring-buffer/preview ciphertext to be
  **recoverable** after a reload — so every replay-bound output is ALSO sealed under the **host-scoped
  recoverable** `K_content = deriveContentKey({ hostContentSecret, sessionId, alg })`, **DISTINCT** from the
  live `h2c` frame. The browser re-derives the identical `K_content` (via P5) and calls `openReplayCiphertext`
  (P6 T9). This is the single agent-side consumer of the FIX 3 recoverable key (closes the "ship as
  unconsumed library code" gap noted in RECONCILE FIX 3a).
- **Contracts** (imports the FROZEN §4.4 replay surface from `relay-contracts/`; re-declares nothing):
  ```ts
  import { deriveContentKey, sealReplayFrame } from 'relay-e2e'   // P4 impls of the §4.4 replay surface
  import type { ReplayKeyParams, AeadKey, E2EEnvelope, AeadAlg } from 'relay-contracts'   // §4.4 FROZEN shapes
  export interface ReplaySealer {
    seal(plaintext: Uint8Array): E2EEnvelope   // K_content seal, monotonic seq per session (INV13); NOT the live h2c frame
  }
  // K_content derived once per (host, session): deriveContentKey({ hostContentSecret, sessionId, alg }).
  export function createReplaySealer(hostContentSecret: Uint8Array, sessionId: string, alg: AeadAlg): ReplaySealer
  ```
  `hostContentSecret` comes from `Keystore.loadContentSecret()` (T3, unwrapped at T4) — **never** the
  ephemeral key, **never** logged, **never** sent to the relay (INV2/INV9).
- **TDD (RED→GREEN)**:
  - [ ] RED: `deriveContentKey` with the same `{ hostContentSecret, sessionId, alg }` is **deterministic**
        (a reload re-derives the identical `K_content`); a different `sessionId` → a different key (per-session).
  - [ ] RED: `seal` emits a §4.4 `E2EEnvelope` with **monotonic seq** (INV13); a known plaintext marker
        never appears in the envelope bytes (INV2).
  - [ ] RED: for the same plaintext, the replay seal is **distinct** from the live `session.seal` (h2c) output
        (different key ⇒ different ciphertext) — the two seals are not interchangeable (FIX 3).
  - [ ] RED (cross-plan vector): a `sealReplayFrame` envelope round-trips through P6's `openReplayCiphertext`
        against a shared test vector (same `hostContentSecret`/`sessionId`).
  - [ ] GREEN by wiring P4 impls.
- **Test cases**: deterministic `K_content` re-derivation; per-session key separation; monotonic seq;
  envelope-only (INV2); replay-seal ≠ live-seal; P6 round-trip vector.
- **Security**: **INV2** (replay ciphertext still opaque to the relay), **INV13** (monotonic seq on replay
  frames too), **INV5/INV9** (`hostContentSecret` at rest `0600`, never logged/egressed). The recoverable
  `K_content` is **host-scoped + per-host revocable** (P3 can invalidate the wrap, INDEX §4.5) — **not** an
  account-wide secret.

---

### W5 · Distribution + acceptance

#### T16 · Static-binary build `[ ]` — v0.9 (EXPLORE §6 distribution rank 2)
- **Owns**: `agent/src/dist/buildBinary.ts`, `agent/test/buildBinary.test.ts`
- **Depends**: T5 (CLI). **Parallel-safe**: with T17.
- **Contracts**: `export function buildBinaryConfig(target: BinaryTarget): BuildSpec` where
  `BinaryTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64'` (string-literal union);
  produces a `bun --compile` spec (fallback `pkg`) for a one-`curl | sh` install. `npx web-terminal-agent`
  remains the MVP path (EXPLORE §6 rank 1 — "customers already `npm install`").
- **TDD**: RED — build spec targets each supported triple; entry = `cli.ts`; the binary bundle **excludes**
  any dev/test dep and any terminal parser (INV11 re-checked at package boundary). GREEN.
- **Test cases**: per-target spec; entrypoint correct; no ANSI-parser in the bundle graph; `npx` path
  documented.
- **Security**: EXPLORE §4d — the agent is a **signed** artifact (a compromised update = same blast radius,
  so the channel is signed/pinned; **signing itself is P5/CI-owned**, this task exposes the hook).

#### T17 · Service install + Origin touch-point `[ ]` — v0.9 (EXPLORE §6)
- **Owns**: `agent/src/service/install.ts`, `agent/src/service/launchd.ts`,
  `agent/src/service/systemd.ts`, `agent/src/service/originConfig.ts`,
  `agent/test/install.test.ts`, `agent/test/originConfig.test.ts`
- **Depends**: T2, T5. **Parallel-safe**: with T16.
- **Contracts**:
  ```ts
  export type ServicePlatform = 'launchd' | 'systemd'
  export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null
  export function installService(cfg: AgentConfig, platform: ServicePlatform): Promise<void>   // writes+loads unit
  export function uninstallService(platform: ServicePlatform): Promise<void>
  // the ONE base-app touch-point (INDEX §0, EXPLORE §3): add the subdomain to ALLOWED_ORIGINS as CONFIG
  export function ensureAllowedOrigin(baseAppEnvPath: string, subdomain: string, domain: string): void
  ```
  `ensureAllowedOrigin` **appends** `https://<subdomain>.term.<domain>` to the base app's
  `ALLOWED_ORIGINS` env (idempotent) — **no `src/` code edit** (EXPLORE §3 "Zero code change"). The service
  runs the agent as the **logged-in user, not root** (EXPLORE §4d — least privilege).
- **TDD**: RED — launchd plist / systemd unit contain the right `ExecStart` (`web-terminal-agent run`),
  restart-on-failure, and **run-as-user (never root)**; `ensureAllowedOrigin` is idempotent and never
  removes an existing origin (does not weaken the Origin check — EXPLORE §3). GREEN with a mock FS.
- **Test cases**: plist/unit generation; run-as-user assertion (negative: refuses to install as root);
  idempotent origin append; existing origins preserved; uninstall unloads cleanly.
- **Security**: least-privilege service (INV-adjacent, EXPLORE §4d); the Origin touch-point **augments,
  never weakens**, CSWSH protection (EXPLORE §3, INV15/INV6 spirit); no secrets written to the unit file
  (INV9 — key/cert stay in the keystore, referenced by path).

#### T18 · Acceptance / verification harness `[ ]` — spans phases
- **Owns**: `agent/test/acceptance/*.test.ts` (agent-local integration), `agent/test/security/*.test.ts`
- **Depends**: all above; **cross-plan** integration with P1/P3/P4 lives in their acceptance suites (this
  is the agent-local slice — do not edit other packages).
- **Steps**:
  - [ ] **INV tripwires** (one per owned INV, §1): INV2 marker-never-on-wire; INV4 no-private-key-egress;
        INV5 keystore-0600 + code-not-at-rest; INV9 no-secret-in-logs; INV11 no-parser-in-bundle;
        INV12 revoke→teardown+no-reconnect; INV14 mTLS-required + rotation.
  - [ ] **INV1 defense-in-depth tripwire (T8)**: an OPEN whose `MuxOpen.subdomain !== cfg.subdomain` is RST
        and **never dialed** — the agent's last-line cross-tenant boundary check.
  - [ ] **INV12 GOAWAY-reason tripwire (T14)**: a `'revoked'` GOAWAY (decoded via the frozen
        `decodeGoAwayReason`) tears down + suppresses reconnect; `'operatorDrain'` **and** `'shutdown'`
        reconnect elsewhere; an **unknown reason code fails closed to revoked**; assert no bare integer reason
        literal in `revocation.ts` (cross-plan-drift guard).
  - [ ] **INV2 anti-MITM no-stub gate (T15)**: with P4's **forged-proof vector**, a failing `deviceAuthProof`
        → ABORT (no `DirectionalKeys`). The **verifier-mutation guard** — replacing the injected
        `verifyDeviceProof` (P5-owned, wired via P4's `createHostHandshake`, FIX 6b) with a no-op makes this
        test FAIL — must be present and passing (proves T15 cannot ship against a stub, and that no verifier is
        imported directly from `relay-e2e`).
  - [ ] **FIX 3 recoverable-replay tripwire (T19)**: a replay-bound output sealed with `sealReplayFrame`
        (`K_content`) round-trips through P6's `openReplayCiphertext`, is **ciphertext to the relay** (INV2),
        and is **distinct** from the live `h2c` frame for the same plaintext.
  - [ ] **Café-demo agent slice**: pair (mock P3) → dial (mock P1) → OPEN → loopback splice against a
        stub `ws://127.0.0.1:3000` echo server → bytes flow both ways (EXPLORE §5 demo, agent portion).
  - [ ] Coverage ≥ 80% across `agent/src/**`.
- **Accept**: all INV tripwires green (incl. the T8 subdomain boundary, T14 GOAWAY-reason fail-closed, the
  T15 no-stub anti-MITM guard, and the T19 recoverable-replay round-trip); café-demo slice green; coverage
  gate met. **T15 must not be marked accepted while its injected-`verifyDeviceProof` gate (Q#2) is
  unresolved** (T14's `GoAwayReason` contract is frozen in INDEX §4.1 — no longer a gate).

---

## 4. SECURITY (per-plan summary)

The host-agent is a **CIPHERTEXT-shuttle endpoint on the customer's own machine** (INDEX §0). Its security
posture:

1. **Private key never leaves the host (INV4)** — Ed25519 generated locally (T3); only the **public** key
   + a CSR are transmitted (T4); mTLS proves possession without sending the key (T12). A relay DB dump
   cannot impersonate a host (EXPLORE §4a.1 "a per-host public key in your DB is useless to a thief").
2. **No shared secrets on the wire (INV4/INV14)** — the tunnel is authenticated by **mTLS**, not a bearer
   token; certs are **short-lived and auto-rotated** (T13); revocation = stop renewing + tear down (T14,
   INV12), where a **`revoked`** GOAWAY is distinguished from the graceful **`operatorDrain`/`shutdown`**
   GOAWAY reasons only via the **frozen** 3-value `relay-contracts` `GoAwayReason`/`decodeGoAwayReason` (never
   a locally-invented numeric mapping — a drift there would silently downgrade a revoke to a reconnect;
   unknown reason ⇒ fail-closed to revoked). The
   v0.8 shared `agentToken` (T6) is an explicit, retired stepping-stone.
3. **Relay sees only ciphertext (INV2)** — from v0.10, every byte entering the tunnel is a §4.4
   `E2EEnvelope` sealed at the agent: **live** frames under the ephemeral `DirectionalKeys.h2c` (T15),
   **replay-bound** frames under the recoverable `K_content` via `sealReplayFrame` (T19, FIX 3) — both
   ciphertext to the relay. The handshake yields **`DirectionalKeys{c2h,h2c}`** (FIX 2, no single
   `sessionKey`) and is **enrollment-key-bound AND device-auth-proof-gated** so a malicious relay cannot MITM
   (browser pins `enroll_fpr`; the agent runs P4's `createHostHandshake` with the **injected, P5-owned**
   `verifyDeviceProof` bound to `{clientEphPub, clientNonce}` (FIX 6b) and ABORTS on a forged `ClientHello`,
   deriving no keys). **This guarantee is only real once that injected `verifyDeviceProof` is P5's real
   verifier (wired via P4), never imported from `relay-e2e` — T15 is BLOCKED, not degraded, against any stub,
   enforced by a no-stub CI mutation guard (§6 Q#2).** Anti-replay/injection via per-message nonce + monotonic
   seq (INV13).
3b. **Agent-side cross-tenant boundary (INV1 defense-in-depth)** — although tenant authz is P1/P5's job and
   there is one physical tunnel per host, `handleOpen` (T8) still refuses to dial any `OPEN` whose
   `MuxOpen.subdomain` ≠ the agent's enrolled `subdomain`, RST-ing it and audit-logging the mismatch. Cheap,
   load-bearing, and the last line against a cross-wired relay frame — consistent with "don't fully trust
   the relay."
4. **No terminal parsing anywhere in the agent (INV11)** — even after E2E decrypt, bytes are handed to the
   loopback WS opaque; the package has **no** ANSI/xterm dependency (enforced by a bundle-graph tripwire,
   T1/T16).
5. **No plaintext/secret at rest (INV5)** — key/cert `0600`; raw pairing code never persisted or logged;
   no shell bytes buffered (pure shuttle, per-connection allocation, **no global buffers** — closes the
   EXPLORE §4b cross-tenant-buffer-bleed failure mode).
6. **Secrets validated at startup, never logged (INV9)** — Zod-validated config; fail-fast on missing key
   material; a redacting logger; **no `console.log`**.
7. **Least privilege (EXPLORE §4d)** — the installed service runs as the **logged-in user, not root**
   (T17); the loopback target is **loopback-only** (anti-SSRF, T2/T8).
8. **Origin check augmented, never weakened (EXPLORE §3)** — the agent replays the real browser `Origin`
   (§4.1 `MuxOpen.originHeader`) to the base app and only **adds** the subdomain to `ALLOWED_ORIGINS` at
   install (T17); CSWSH protection stays meaningful end-to-end.

**Out-of-scope security owned elsewhere (do not implement here):** capability-token verification, human
auth/step-up, the CI cross-tenant tripwire, the revocation *policy/DB*, the mTLS *CA* → **P5/P3**; the E2E
crypto *primitives* → **P4**; the relay-side isolation enforcement → **P1/P5**.

---

## 5. VERIFICATION

```bash
# Unit + security tripwires (agent-local)
npm --prefix agent run typecheck                 # strict TS; imports relay-contracts / relay-e2e read-only
npm --prefix agent test -- config logger         # INV9 boundary + redaction
npm --prefix agent test -- identity keystore      # INV4 (no private-key egress) + INV5 (0600)
npm --prefix agent test -- pair csr               # §4.5 redemption; code-not-at-rest; single-use respect
npm --prefix agent test -- tunnel streamRouter loopback   # §4.1 demux, RST-on-illegal, INV11 opaque, 2-stream isolation
npm --prefix agent test -- heartbeat backoff flowControl  # 15s PING/PONG, 1/2/4…cap-30s, per-stream fairness
npm --prefix agent test -- dial rotation revocation       # INV14 mTLS + rotation, INV12 revoke→teardown+no-reconnect
npm --prefix agent test -- hostEndpoint replaySeal # §4.4 handshake→DirectionalKeys vs P4 vector, INV2 envelope-only, INV13 replay/reorder/tamper; recoverable K_content seal (FIX 3)
npm --prefix agent test -- install originConfig    # run-as-user (not root), idempotent ALLOWED_ORIGINS append
npm --prefix agent test -- acceptance security     # all owned-INV tripwires + café-demo agent slice
npm --prefix agent run test:coverage               # ≥ 80% across agent/src/**

# Manual café-demo (cross-plan, once P1+P3 exist): on a host behind NAT
npx web-terminal-agent pair ABCD-1234              # generates Ed25519 locally, redeems code, installs service
web-terminal-agent status                          # prints host_id/subdomain/online — NO key/cert material
# then open https://<subdomain>.term.<domain> on a phone → land in the host shell (P6 verifies the browser end)
```

**Key manual checks:** (1) after `pair`, the private key file is `0600` and the request that hit `/enroll`
carried **only** pubkey+CSR (INV4); (2) `status` leaks no secrets (INV9); (3) killing the relay node mid-
session → the agent backs off (1/2/4…) and reconnects, **PTY survives** on the host (EXPLORE §3, INV7 on
the relay side); (4) revoking the host → its tunnel drops within seconds and does not reconnect (INV12);
(5) with E2E (v0.10) a plaintext marker typed in the shell never appears in captured relay traffic (INV2).

---

## 6. Open questions (for the orchestrator — do NOT guess; return `[!] BLOCKED` if hit mid-task)

1. **CSR format for an Ed25519 SPIFFE-style cert** — §4.5 says the agent sends a `csr`, but the exact
   profile (PKCS#10 vs a SPIFFE SVID request; SAN = the subdomain vs a SPIFFE URI) is set by **P3's CA**.
   T4/T13 must consume P3's chosen shape; if P3 hasn't frozen it, T4 is BLOCKED on P3.
2. **[HARD PRE-W4 GATE — ownership RESOLVED by FIX 6b; the freeze/vector gate remains] `deviceAuthProof`
   verifier (§4.4 `ClientHello`).** Ownership is now settled: **P5 issues+verifies** the proof
   (`verifyDeviceProof(proof, { clientEphPub, clientNonce })`); **P4 wires it** into `createHostHandshake`;
   **P2/T15 consumes it INJECTED through P4** — never `import { verifyDeviceAuthProof } from 'relay-e2e'` (the
   old import was wrong, INDEX §6b). What remains a hard gate is timing: because the entire anti-MITM
   guarantee of authenticated-ECDH-through-the-relay rests on this check, **T15 is BLOCKED (not degraded)
   until P5's verifier is frozen, wired into P4's `createHostHandshake`, AND a cross-plan test vector (a valid
   proof + ≥1 forged/failing proof) is published.** T15 must never ship against a stub/no-op/always-true
   verifier (that would let a forged `ClientHello` complete a handshake and derive `DirectionalKeys` — the
   exact threat). A no-stub CI guard (verifier-mutation test) enforces this at the build. If unmet at
   dispatch, the builder returns `[!] BLOCKED`; the orchestrator lands the verifier+vector, then re-dispatches
   T15.
3. **[RESOLVED — §4.1 addendum frozen] GOAWAY reason-code semantics.** INDEX §4.1 (line 225) now freezes the
   mapping as `GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'` (string-literal union) +
   `decodeGoAwayReason` (`1→operatorDrain · 2→revoked · 3→shutdown`, else throws) — the **same** codes P1
   `drain.ts` `DRAIN_REASON` (`operatorDrain:1, revoked:2, shutdown:3`) and P5 revocation (FIX 4) emit. T14
   imports the union + decoder **verbatim** from `relay-contracts/` (no local redefinition), maps
   `operatorDrain`/`shutdown` → drain (reconnect elsewhere) and `revoked` → revoke (stop), and **fails closed
   to `revoked` on any unknown numeric code** (`decodeGoAwayReason` throws). This closes the earlier drift
   where P2 assumed a 2-value `'drain' | 'revoked'` union the frozen decoder never emits (a genuine revoke
   misread as a drain would silently defeat INV12 with both plans' unit tests still green). **T14 is no longer
   BLOCKED on this** — the contract is frozen in §4 per this INDEX's rule that shared semantics live there.
4. **v0.8 `agentToken` transport auth vs frp's own token** — T6 wraps `frpc`; whether the shared token is
   frp's `auth.token` or a P3-issued value affects the P3 flat-table schema (§4.2 note). Confirm with P3
   before wiring T6's `frpc.toml`.
5. **Cert-rotation renewal endpoint contract** — T13 posts a renewal CSR; the URL/auth (mTLS with the
   *current* cert? a short renewal token?) is **P3**. Blocked on P3 freezing the renewal route.
6. **Multi-device key distribution channel (§4.4)** — "authenticated (account-derived) channel, not a
   plaintext QR": the agent side (T15) derives per-device keys via per-stream handshakes; whether any
   agent-side coordination is needed for the *same* session across devices is a **P4** design point —
   confirm the agent has no extra responsibility beyond per-stream handshake.
```
