# 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, 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/ 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./agent — validated wss:// only readonly enrollUrl: string // https:///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 // wss/https/ws enforced by refine export function loadAgentConfig(env: NodeJS.ProcessEnv, argv: Partial): 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): 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 // 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> } export function parseArgs(argv: readonly string[]): CliArgs export function runCli(args: CliArgs, deps: CliDeps): Promise // returns process exit code ``` `runCli` dispatches: `pair ` → 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; stop(): Promise; 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=`, 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 // dials ws://127.0.0.1:3000, sets header `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, backoff: BackoffPolicy, isRevoked: () => boolean): Promise ``` - **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 // 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 // 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 // writes+loads unit export function uninstallService(platform: ServicePlatform): Promise // 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://.term.` 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://.term. 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. ```