Files
web-terminal/docs/PLAN_RELAY_AGENT.md
Yaojia Wang 2af57e6686 feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
2026-07-02 06:10:16 +02:00

76 KiB
Raw Blame History

P2 — Host Agent (agent/) — Implementation Plan

Plan of the 6 (see PLAN_RELAY_INDEX.md — the coordination point / analog of src/types.ts). This plan owns the agent/ package only. Source of intent: EXPLORE_RELAY_SERVICE.md §0 LOCKED DECISIONS (AUTHORITATIVE, CLOSED) + §3 host-agent lifecycle + §6 distribution. Style: PLAN.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 imports 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 + mTLSwss://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. Distributionnpx 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 tableP3 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/DBP5. 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 renderP6.
  • 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 (200400 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:
    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):
    // 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):
    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 payloadAgentIdentity 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 25 verbatim (agent side), validating the response with the frozen pairing Zod schema from relay-contracts/:
    // §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:
    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 (T7T11) replaces it in v0.9; the §4.1 frame path is designed so ciphertext (T15) drops in without reshaping.
  • Contracts:
    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 T8T11 (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/):
    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 T9T11.

  • Contracts (validates MuxOpen with the frozen §4.1 Zod schema from relay-contracts/):

    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 allocationno 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):

    // 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.outboundtunnel.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):
    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 T14reconnectLoop 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).
  • Contractsreuses the base app's 1/2/4…cap-30s policy (EXPLORE §3 "the same policy the frontend already ships"):
    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 T8T10 (own file).
  • Contractsconsumes 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):
    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):
    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):
    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:
    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 GoAwayReasondecodeGoAwayReason 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 GATEP4 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 imports 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:

    // 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 verifyDeviceProofABORT (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 ClientHellos derive two independent HandshakeResults 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):
    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:
    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 CAP5/P3; the E2E crypto primitivesP4; the relay-side isolation enforcement → P1/P5.


5. VERIFICATION

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