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

993 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (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**:
```ts
export interface AgentConfig {
readonly relayUrl: string // wss://relay.<domain>/agent — validated wss:// only
readonly enrollUrl: string // https://<domain>/enroll — https:// only
readonly stateDir: string // where key/cert live (default ~/.web-terminal-agent)
readonly localTargetUrl: string // ws://127.0.0.1:3000 (the UNCHANGED web-terminal)
readonly subdomain: string | null // filled after pairing
readonly hostId: string | null // filled after pairing (§4.2)
}
export const AgentConfigSchema: ZodType<AgentConfig> // wss/https/ws enforced by refine
export function loadAgentConfig(env: NodeJS.ProcessEnv, argv: Partial<AgentConfig>): AgentConfig
// logger — redacts key/cert/code/token fields; no console.log
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
export function createLogger(level: LogLevel): { log(l: LogLevel, msg: string, meta?: Record<string, unknown>): void }
```
**`agent/src/transport/seams.ts` — W0 shared injection seams (DEPENDENCY-CYCLE BREAKER).** These are
intra-`agent/` seam *types only* (no cross-plan frozen contract lives here — those stay in
`relay-contracts/`). Declaring them at W0 lets W2/W3 tasks consume a stable type **without a task-level
cycle** (see the T10↔T14 note in §3):
```ts
// Minimal WS surface the transport layer needs (dial/tunnel/backoff share it; no per-task redeclare).
export interface WsLike {
send(d: Uint8Array): void
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void
close(): void
}
// Revocation seam — T14 IMPLEMENTS it, T10's reconnectLoop only CONSUMES the isRevoked() callback.
export type RevokeReason = 'renewal-refused' | 'goaway-revoked' | 'operator'
export interface RevocationState { isRevoked(): boolean; markRevoked(reason: RevokeReason): void }
```
Both `WsLike` and `RevocationState`/`RevokeReason` are **defined once here** and imported by T7/T10/T12/T14;
they are **not** redeclared in any W2/W3 task (kills the drift and the cycle at the type level).
- **TDD (RED→GREEN)**:
- [ ] RED: `relayUrl='http://…'` → throws (only `wss://`); `localTargetUrl` non-loopback host → throws
(INV: agent forwards **only** to loopback, never an arbitrary target — anti-SSRF).
- [ ] RED: logger given `{ privateKey, cert, pairingCode, agentToken }` meta → asserts those values
never appear in emitted string (INV9).
- [ ] GREEN: implement with Zod `.refine`.
- **Test cases**: valid config parses; missing `relayUrl` → fail-fast; `localTargetUrl=ws://10.0.0.5:3000`
→ rejected (loopback-only); `localTargetUrl=ws://127.0.0.1:3000` → ok; logger redaction of each secret
field; `debug` meta with a `nonce` key passes through (not a secret); **seams**: `seams.ts` exports
`WsLike`, `RevocationState`, `RevokeReason` and is `import`-able with **no runtime dependency** (type-only
file — a test asserts the compiled module has no side effects and no `ws`/crypto import), so W2/W3 can
depend on it without pulling transport runtime.
- **Security**: INV9 (validated at startup, never logged), loopback-only target (anti-SSRF); the seam file
is types-only (no secret handling).
#### T3 · Agent identity + keystore `[ ]` — v0.9 (contract stub usable in v0.8)
- **Owns**: `agent/src/keys/identity.ts`, `agent/src/keys/keystore.ts`,
`agent/test/identity.test.ts`, `agent/test/keystore.test.ts`
- **Depends**: T1, T2. **Parallel-safe**: with T2.
- **Contracts** (uses `node:crypto` `generateKeyPairSync('ed25519')`; **no** third-party crypto):
```ts
export interface AgentIdentity {
readonly publicKey: Uint8Array // Ed25519 raw pubkey → stored in host registry (§4.2 agent_pubkey)
readonly enrollFpr: string // §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by browser E2E TOFU
sign(message: Uint8Array): Uint8Array // uses private key IN-PROCESS; key never returned/serialized off-host
}
export function generateIdentity(): AgentIdentity // private key held in-memory only
export function computeEnrollFpr(publicKey: Uint8Array): string
// keystore — persists ONLY to stateDir with 0600 (INV4/INV5)
export interface Keystore {
saveIdentity(id: AgentIdentity): void // writes private key PEM 0600 + pubkey
loadIdentity(): AgentIdentity | null
saveCert(certPem: string, caChainPem: string): void // 0600
loadCert(): { certPem: string; caChainPem: string } | null
saveContentSecret(secret: Uint8Array): void // 0600 — FIX 3: the §4.5 hostContentSecret, UNWRAPPED with the enrollment key
loadContentSecret(): Uint8Array | null // input to §4.4 deriveContentKey (ReplayKeyParams); never logged/egressed
}
export function openKeystore(stateDir: string): Keystore
```
- **TDD (RED→GREEN)**:
- [ ] RED: `generateIdentity()` twice → distinct pubkeys; `sign`/verify round-trips against
`crypto.verify`.
- [ ] RED: `computeEnrollFpr` is deterministic + matches the §4.2 `enroll_fpr` derivation P4/P6 pin.
- [ ] RED: `saveIdentity` then read the file mode → **`0600`** (INV5); a world-readable dir triggers
a hard error, not a silent write.
- [ ] RED (security): **assert there is no code path that serializes the private key into any network
payload** — `AgentIdentity` exposes only `publicKey`, `enrollFpr`, `sign` (INV4). Grep test:
no export/return of raw private-key bytes.
- [ ] GREEN.
- **Test cases**: keygen uniqueness; sign/verify; fpr determinism + cross-checks fixture P4/P6 will pin;
keystore perms `0600`; reload identity yields same pubkey/fpr; corrupt key file → typed error;
**negative**: no API returns/serializes the private key; **FIX 3**: `saveContentSecret`/`loadContentSecret`
round-trip the `hostContentSecret` bytes, file mode `0600`, and the secret never appears in any log line.
- **Security**: **INV4** (private key never leaves host — this is the load-bearing task), **INV5** (0600 at
rest — key/cert **and** the FIX 3 `hostContentSecret`). Cross-check `enroll_fpr` derivation with P4 (§4.4
TOFU pin) and P3 (§4.2 storage).
---
### W1 · Enrollment (v0.8 skeleton → v0.9 real Ed25519)
#### T4 · Pairing redemption + CSR `[ ]` — v0.9 (§4.5) — v0.8 ships a token-gate variant
- **Owns**: `agent/src/enroll/pair.ts`, `agent/src/enroll/csr.ts`,
`agent/test/pair.test.ts`, `agent/test/csr.test.ts`
- **Depends**: T3 (identity), T2 (config); **cross-plan**: P3 `/enroll` endpoint (§4.5 BIND/RETURN) —
mocked in unit tests. **Parallel-safe**: with T5/T6.
- **Contracts** — implements INDEX **§4.5** steps 25 verbatim (agent side), validating the response with
the **frozen pairing Zod schema** from `relay-contracts/`:
```ts
// §4.5 step 3 REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5)
// §4.5 step 5 RETURN → the FROZEN `EnrollResult` — IMPORTED from relay-contracts, NOT redeclared here (FIX 3).
// Frozen shape (INDEX §4.5): { hostId, subdomain, cert, caChain, hostContentSecret } — the last field is
// the FIX 3 host-scoped secret, WRAPPED to this agent's enrolled Ed25519 identity (agent_pubkey) by P3 at BIND.
import type { EnrollResult } from 'relay-contracts'
export function buildCsr(id: AgentIdentity, subject: string): string // PKCS#10 over the Ed25519 key
export function redeemPairingCode(
enrollUrl: string, code: string, id: AgentIdentity, ks: Keystore, fetchImpl?: typeof fetch,
): Promise<EnrollResult>
// On success (FIX 3): UNWRAP EnrollResult.hostContentSecret with the enrollment private key (in-process, INV4)
// and persist the UNWRAPPED secret 0600 via ks.saveContentSecret (T3) — the WRAPPED bytes are never stored,
// the unwrapped secret is never logged or re-sent (INV5/INV9). Feeds §4.4 deriveContentKey (T19).
```
- **TDD (RED→GREEN)**:
- [ ] RED: `redeemPairingCode` sends `{ code, agentPubkey, csr }`; assert the **private key is NOT in
the request body** (INV4) — only pubkey + CSR.
- [ ] RED: raw `code` is **never written to `stateDir`** and never logged (INV5/INV9).
- [ ] RED: malformed/HTTP-error `/enroll` response → typed `EnrollError` (no swallow); response not
matching the frozen §4.5 schema → reject (boundary validation).
- [ ] RED (single-use): a `409 already-redeemed` from P3 → surfaced as `PairingCodeSpentError`
(agent must not retry-spam a spent code).
- [ ] RED (**FIX 3**): `EnrollResult.hostContentSecret` (wrapped to the agent identity) is UNWRAPPED with
the enrollment private key and stored `0600` via `Keystore.saveContentSecret` (T3); assert the
**wrapped** bytes are never persisted and the unwrapped secret is never logged (INV5/INV9).
- [ ] GREEN.
- **Test cases**: happy path returns the frozen §4.5 shape `{hostId, subdomain, cert, caChain,
hostContentSecret}` and keystore stores cert `0600` **and the unwrapped `hostContentSecret` `0600`**; body
omits private key; expired-code (`410`) → `PairingCodeExpiredError`; spent-code (`409`) →
`PairingCodeSpentError`; schema-mismatch response → rejected; `enrollUrl` non-https → refused (T2 guard).
**v0.8 variant**: when no Ed25519 flow is enabled, `redeemPairingCode` degrades to presenting the shared
`agentToken` (still hashed server-side by P3) — behind an explicit `EnrollMode = 'token' | 'ed25519'`
string-literal union; a test asserts `'ed25519'` is the default from v0.9.
- **Security**: **INV4** (only pubkey+CSR leave), **INV5** (raw code not at rest), boundary validation on
the enroll response (untrusted external data), single-use respect.
#### T5 · CLI entrypoint `[ ]` — v0.8 skeleton → extended each wave
- **Owns**: `agent/src/cli.ts`, `agent/test/cli.test.ts`
- **Depends**: T2, T3, T4 (and later T7/T13/T17 handlers). **Parallel-safe**: with T4/T6.
- **Contracts**:
```ts
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
export interface CliArgs { readonly command: CliCommand; readonly code?: string; readonly flags: Readonly<Record<string, string | boolean>> }
export function parseArgs(argv: readonly string[]): CliArgs
export function runCli(args: CliArgs, deps: CliDeps): Promise<number> // returns process exit code
```
`runCli` dispatches: `pair <code>` → T4 redeem → keystore + config write → optional T17 install;
`run` → T7 tunnel loop; `status` → read keystore/config, print host_id/subdomain/online (no secrets);
`install`/`uninstall` → T17. Dependencies injected (`CliDeps`) so tests avoid real network/FS.
- **TDD**: RED parse `pair ABCD-1234` → `{command:'pair', code:'ABCD-1234'}`; unknown command → nonzero
exit + typed error (no throw to bare stack); `status` prints **no** key/cert material (INV9); missing
`code` on `pair` → usage error. GREEN.
- **Test cases**: arg parsing matrix; `pair` happy path calls redeem then install (mocked); `status`
redaction; unknown command exit code; `run` before pairing → "not enrolled" error (fail-fast).
- **Security**: INV9 (status shows no secrets); fail-fast when unenrolled.
#### T6 · frp scaffold (v0.8 stepping-stone) `[ ]` — v0.8 ONLY (retired at v0.9)
- **Owns**: `agent/src/transport/frpScaffold.ts`, `agent/test/frpScaffold.test.ts`
- **Depends**: T2, T5. **Parallel-safe**: with T4/T5.
- **Rationale (EXPLORE §5 "wrap `frpc` as the agent", INDEX §1 v0.8)**: fastest path to the café demo;
wraps `frpc` (child process) presenting the shared `agentToken`, registering `subdomain`, forwarding to
`127.0.0.1:3000`. **Explicitly a stepping-stone** — the native mux (T7T11) replaces it in v0.9; the
§4.1 frame path is designed so ciphertext (T15) drops in without reshaping.
- **Contracts**:
```ts
export interface FrpScaffold { start(): Promise<void>; stop(): Promise<void>; onExit(cb: (code: number) => void): void }
export function spawnFrpc(cfg: AgentConfig, frpcPath: string): FrpScaffold // generates frpc.toml, spawns child
```
- **TDD**: RED generated `frpc.toml` has `local_ip=127.0.0.1 local_port=3000`, `subdomain=<cfg>`, tls
enabled; child crash → `onExit` fires and (paired with T10) triggers backoff. GREEN with a mocked
spawn.
- **Test cases**: toml generation; child-exit propagation; **negative**: never forwards to a non-loopback
`local_ip`. **Retirement test**: a v0.9 guard test asserts `frpScaffold` is not imported by `run`
once `EnrollMode==='ed25519'` (prevents shipping both substrates).
- **Security**: loopback-only forward; the shared token is a **v0.8-only** shortcut (INDEX §1 — "must reach
real accounts before the first paid signup").
---
### W2 · Native mux tunnel (v0.9) — consumes P1 §4.1
#### T7 · Tunnel holder `[ ]` — v0.9
- **Owns**: `agent/src/transport/tunnel.ts`, `agent/test/tunnel.test.ts`
- **Depends**: T2 (W0 `seams.ts` `WsLike`), T3, dial (T12) at integration; **cross-plan**: P1 §4.1 codec
(`encodeMuxFrame`/`decodeMuxFrame`) from `relay-contracts/`. **Parallel-safe**: with 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/`):
```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 T9T11.
- **Contracts** (validates `MuxOpen` with the **frozen §4.1 Zod schema** from `relay-contracts/`):
```ts
export interface StreamRouter {
handleOpen(open: MuxOpen): void // one OPEN ⇒ one loopback socket — AFTER the subdomain boundary check
handleData(streamId: number, payload: Uint8Array): void
handleClose(streamId: number, rst: boolean): void
activeStreamCount(): number
}
export function createStreamRouter(cfg: AgentConfig, tunnel: Tunnel, dialLoopback: DialLoopback, transform: FrameTransform): StreamRouter
export type DialLoopback = (path: string, origin: string) => Promise<WsLike>
// dials ws://127.0.0.1:3000<path>, sets header `Origin: <origin>` from §4.1 MuxOpen.originHeader
export function dialLoopback(target: string): DialLoopback
// FrameTransform: identity in v0.9 (plaintext passthrough); replaced by the E2E codec in v0.10 (T15)
export interface FrameTransform {
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null // tunnel→local (decrypt in v0.10)
outbound(streamId: number, plain: Uint8Array): Uint8Array // local→tunnel (encrypt in v0.10)
openStream(streamId: number): void; closeStream(streamId: number): void
}
export const identityTransform: FrameTransform // v0.9: inbound/outbound = passthrough
```
**Per-stream state is a fresh allocation** — **no global mutable buffers** (EXPLORE §4b failure-mode #3;
cross-tenant buffer bleed is impossible because each stream owns its socket + transform state). Closing a
stream (CLOSE/RST) frees the paired loopback socket (§4.1).
**MANDATORY subdomain boundary check (defense-in-depth against a compromised/buggy/misconfigured relay).**
Per §4.1, authorization is the relay's job (P5) and there is exactly **one physical tunnel per host**, so a
correctly-functioning relay never delivers a foreign-tenant `OPEN` here. But the whole premise is *don't
fully trust the relay* — the agent is the last place that can catch a cross-wired frame. Therefore, the
**first** thing `handleOpen` does — before allocating any state or dialing loopback — is compare the frozen
`MuxOpen.subdomain` (§4.1) against this agent's own enrolled `AgentConfig.subdomain` (§4.2, filled at
pairing):
```ts
// handleOpen — first statement, before any allocation or dial:
if (open.subdomain !== cfg.subdomain) {
// never proceed to dial; RST this stream and audit-log the mismatch (metadata only, INV10 — no payload)
tunnel.send(rstHeaderFor(open.streamId), EMPTY) // §4.1 CLOSE + flags.RST
logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId })
return
}
```
This is a one-field comparison; it closes an otherwise-silent cross-tenant-bleed vector (host B's OPEN
spliced into host A's tunnel) at the single point in the whole system where the agent can detect it. It
**complements, never replaces**, relay-side authz (INV1, owned by P1/P5) — belt-and-suspenders.
- **TDD (RED→GREEN)**:
- [ ] RED: `handleOpen` dials `ws://127.0.0.1:3000` at `MuxOpen.requestPath` **replaying
`MuxOpen.originHeader`** as the `Origin` header (so the UNCHANGED base app's Origin check passes
against `ALLOWED_ORIGINS` — EXPLORE §3).
- [ ] RED: loopback output bytes → `transform.outbound` → `tunnel.send(DATA)`; tunnel DATA →
`transform.inbound` → loopback write (v0.9 passthrough).
- [ ] RED: CLOSE/RST frees the loopback socket; loopback close → agent sends CLOSE upstream.
- [ ] RED (**INV5/isolation**): two concurrent streams get **separate** socket + buffer objects; a
marker written on stream A never appears on stream B's socket (per-connection allocation).
- [ ] RED (**INV1 defense-in-depth / cross-tenant boundary**): an `OPEN` whose `MuxOpen.subdomain !==
cfg.subdomain` is **RST immediately and NEVER dialed** — assert `dialLoopback` is not called, the
stream is RST, and a metadata-only mismatch is logged (INV10). This is the load-bearing negative
test: it must fail if a future refactor drops the guard.
- [ ] RED: `MuxOpen` failing the frozen §4.1 schema → stream RST, not a crash.
- [ ] GREEN.
- **Test cases**: OPEN→dial with correct path+Origin; bidirectional splice; CLOSE frees socket; RST on
invalid MuxOpen; **OPEN for a foreign subdomain is RST, never dialed** (cross-tenant boundary — assert no
loopback dial); **two-stream isolation** (no cross-stream bleed); loopback connect-refused (base app
down) → RST upstream + typed error (no swallow); backpressure hook calls into T11.
- **Security**: **INV11** (opaque splice), **INV5** (per-connection allocation, no global buffers),
**INV1 defense-in-depth** (subdomain-mismatch OPEN is RST before dial — the agent's own last-line check on
a cross-wired relay frame; complements, never replaces, relay-side authz owned by P1/P5),
Origin replay preserves CSWSH protection end-to-end (EXPLORE §3 "do not weaken the check").
#### T9 · Heartbeat `[ ]` — v0.9
- **Owns**: `agent/src/transport/heartbeat.ts`, `agent/test/heartbeat.test.ts`
- **Depends**: T7. **Parallel-safe**: with T8/T10/T11.
- **Contracts** (§4.1 PING/PONG, streamId 0, **15s** — cited verbatim):
```ts
export interface Heartbeat { onPing(token: Uint8Array): void; start(): void; stop(): void; onDead(cb: () => void): void }
export function createHeartbeat(tunnel: Tunnel, opts?: { intervalMs?: number; timer?: TimerLike }): Heartbeat
export const HEARTBEAT_INTERVAL_MS = 15_000 // §4.1: "heartbeat every 15s, miss ⇒ tunnel dead"
```
- **TDD** (fake timers): RED — inbound PING → agent replies PONG echoing the 8-byte token; no PONG within
interval → `onDead` fires (⇒ T10 reconnect). GREEN.
- **Test cases**: PING→PONG echo; missed PONG → dead after `HEARTBEAT_INTERVAL_MS`; `stop()` cancels timer
(no leak); token echoed byte-exact.
- **Security**: liveness only; no secret material.
#### T10 · Reconnection / backoff `[ ]` — v0.9
- **Owns**: `agent/src/transport/backoff.ts`, `agent/test/backoff.test.ts`
- **Depends**: T2 (dial trigger **and** the W0 `seams.ts` `isRevoked` seam). **Does NOT depend on T14** —
`reconnectLoop` only *consumes* an injected `isRevoked: () => boolean` callback (the seam type is frozen at
W0 in `seams.ts`), so there is **no task-level cycle** with T14. T14 *implements* `RevocationState` and
wires its `isRevoked` into this loop at integration; T10 never imports T14. **Parallel-safe**: with
T8/T9/T11 (and with T14 — no edge either way).
- **Contracts** — **reuses the base app's 1/2/4…cap-30s** policy (EXPLORE §3 "the *same policy the frontend
already ships*"):
```ts
export interface BackoffPolicy { nextDelayMs(): number; reset(): void }
export function createBackoff(opts?: { baseMs?: number; capMs?: number; jitter?: boolean }): BackoffPolicy
export const BACKOFF_BASE_MS = 1_000
export const BACKOFF_CAP_MS = 30_000 // 1s/2s/4s…cap 30s
export function reconnectLoop(dial: () => Promise<Tunnel>, backoff: BackoffPolicy, isRevoked: () => boolean): Promise<void>
```
- **TDD** (fake timers): RED — delays follow 1s,2s,4s,8s,16s,30s(cap),30s…; `reset()` after a successful
connect returns to 1s; **`isRevoked()===true` short-circuits the loop (no reconnect)** — INV12. GREEN.
- **Test cases**: exact backoff sequence + cap; jitter within bounds when enabled; reset-on-success;
**revoked host does not reconnect** (INV12); dial failure loops, dial success resolves.
- **Security**: **INV12** (a revoked host must stop trying, not thrash the relay).
#### T11 · Per-stream flow control `[ ]` — v0.9
- **Owns**: `agent/src/transport/flowControl.ts`, `agent/test/flowControl.test.ts`
- **Depends**: T7, T8. **Parallel-safe**: with T8T10 (own file).
- **Contracts** — **consumes** the §4.1 `WINDOW_UPDATE` credit protocol (P1 owns the protocol; the agent
is a credit-respecting sender/receiver so one heavy `vim`/`top` redraw can't starve another stream):
```ts
export interface FlowController {
consume(streamId: number, bytes: number): boolean // false ⇒ credit exhausted, pause this stream
grant(streamId: number, credit: number): void // on inbound WINDOW_UPDATE
initWindow(streamId: number, initialCredit: number): void
}
export function createFlowController(): FlowController
```
- **TDD**: RED — sending past the initial window returns `false` (pause); a `WINDOW_UPDATE grant` resumes;
credit is **per-stream** (exhausting A does not pause B). GREEN.
- **Test cases**: pause at zero credit; resume on grant; per-stream independence (starvation guard);
connection-level (streamId 0) credit applies to the whole link (§4.1).
- **Security**: DoS resilience (per-stream fairness); no cross-stream state bleed.
---
### W3 · mTLS + lifecycle (v0.9 / v0.10)
#### T12 · Outbound mTLS dial `[ ]` — v0.9
- **Owns**: `agent/src/transport/dial.ts`, `agent/test/dial.test.ts`
- **Depends**: T3 (key), T4 (cert), T2 (relayUrl); **cross-plan**: P3 CA (validates server cert),
P1 `/agent` endpoint (mocked in unit tests). **Parallel-safe**: with T13/T14.
- **Contracts** (INV14 — mTLS, **no bearer token on the tunnel**, EXPLORE §4a.1):
```ts
import { WsLike } from './seams' // W0 shared seam (T2) — NOT redeclared here
export function dialRelay(cfg: AgentConfig, ks: Keystore): Promise<WsLike>
// builds a wss:// client with { cert, key(in-process), ca: caChain }; rejectUnauthorized: true
```
- **TDD (RED→GREEN)**:
- [ ] RED: dial constructs a TLS client with the **client cert + in-process private key + CA chain**;
`rejectUnauthorized` is **true** (never disabled — a common insecure default).
- [ ] RED: **no bearer/agent token** is placed in a header or the URL (INV4 — mTLS is the auth).
- [ ] RED: absent/expired cert in keystore → dial refused with `NotEnrolledError`/`CertExpiredError`
(fail-fast, INV14).
- [ ] RED: a relay server cert not chaining to the pinned CA → handshake rejected (no MITM).
- [ ] GREEN (mock TLS layer).
- **Test cases**: cert+key+CA wired; `rejectUnauthorized` true; no token header; missing cert →
NotEnrolled; expired cert → refuse; bad server chain → reject.
- **Security**: **INV14** (mTLS), **INV4** (asymmetric, no shared secret on the wire), pinned CA (anti-MITM).
#### T13 · Short-lived cert rotation `[ ]` — v0.9 → hardened v0.10
- **Owns**: `agent/src/certs/rotation.ts`, `agent/test/rotation.test.ts`
- **Depends**: T3, T4, T12; **cross-plan**: P3 renewal endpoint (SPIFFE-style, §4.5 signs only pubkeys in
the registry). **Parallel-safe**: with T14.
- **Contracts** (INV14 — auto-rotating, seamless):
```ts
export interface CertRotator { start(): void; stop(): void; onRotated(cb: () => void): void; onRevoked(cb: () => void): void }
export function createCertRotator(cfg: AgentConfig, id: AgentIdentity, ks: Keystore, opts?: { renewBeforeMs?: number; timer?: TimerLike }): CertRotator
// renews via P3 using a fresh CSR (T4) over the SAME Ed25519 key; installs new cert atomically (INV8-style swap)
```
- **TDD** (fake timers): RED — timer fires at `expiry - renewBeforeMs`, posts a fresh CSR, installs the
new cert **atomically** (old cert never partially overwritten); a `403 revoked` from the renewal
endpoint → `onRevoked` fires (⇒ T14 teardown, INV12); rotation mid-tunnel does **not** drop live
streams (seamless). GREEN.
- **Test cases**: renew-before-expiry timing; atomic cert swap (no torn read of cert file); renewal
refusal (revoked) → onRevoked; renewal uses the same key (pubkey unchanged, only cert rotates);
network error on renew → retry with backoff, tunnel stays up until cert actually expires.
- **Security**: **INV14** (short-lived auto-rotation; revocation = "just stop renewing" per EXPLORE §4a.1),
**INV12** (renewal-refusal path), **INV8** (atomic swap of cert-at-rest).
#### T14 · Revocation + GOAWAY teardown `[ ]` — v0.9 (drain) / v0.10 (revoke)
- **Owns**: `agent/src/lifecycle/revocation.ts`, `agent/test/revocation.test.ts`
- **Depends**: T7 (tunnel), T13 (renewal-refusal), **T2 (the W0 `seams.ts` `RevocationState`/`RevokeReason`
seam)**; **cross-plan**: the **frozen INDEX §4.1 3-value `GoAwayReason`** union + `decodeGoAwayReason` in
`relay-contracts/` (§4.1 line 225 — RESOLVED, no longer an open question). T14 **wires** T10's
`reconnectLoop` (injects its `isRevoked` into the loop) but does **not** depend on T10 as a task — the
edge is one-way (T14→consumes T10's function via the W0 seam type), so there is **no cycle**.
**Parallel-safe**: with T12/T13.
- **Contracts** (INV12 — fast revocation kills live tunnels in seconds). The `RevocationState`/`RevokeReason`
seam is **imported from `agent/src/transport/seams.ts` (T2), not redeclared here**; the GOAWAY reason
mapping is **imported from `relay-contracts/`, never invented locally**:
```ts
import { RevocationState, RevokeReason } from '../transport/seams' // W0 seam (T2)
import { GoAwayReason, decodeGoAwayReason } from 'relay-contracts' // FROZEN §4.1 3-value union + decoder (see below)
export function createRevocationState(onTeardown: () => void): RevocationState
// markRevoked ⇒ close all streams + tunnel immediately AND make T10's isRevoked() true (no reconnect)
// classifyGoAway maps the FROZEN 3-value wire reason to the agent's action:
// 'operatorDrain' → 'drain' · 'shutdown' → 'drain' · 'revoked' → 'revoked' (an unknown numeric code never
// reaches here — decodeGoAwayReason throws, and T14 fails closed to 'revoked'; see below).
export function classifyGoAway(reason: GoAwayReason): 'drain' | 'revoked' // pure exhaustive map over the FROZEN 3-value union
```
**Finding-3 fix (RESOLVED) — GOAWAY reason semantics are a FROZEN shared contract, not a local numeric guess.**
INDEX §4.1 (line 225) now freezes `GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'` (string-literal
union) + `decodeGoAwayReason` (`1→operatorDrain · 2→revoked · 3→shutdown`, else throws) — the **same** codes
P1 `drain.ts` `DRAIN_REASON` (`operatorDrain:1, revoked:2, shutdown:3`) and P5 revocation (FIX 4) emit. T14
imports this union + decoder **verbatim** from `relay-contracts/` and **must not** hard-code reason integers
or redeclare the union locally. This closes the earlier sender/receiver drift where P1 emitted
`operatorDrain`/`shutdown` while P2 assumed a 2-value `'drain' | 'revoked'` union the frozen decoder never
emits — a genuine **revoke** misread as a **drain** would have let the agent *reconnect elsewhere* instead of
*refusing to reconnect*, silently defeating INV12 with **both plans' unit tests still green**. `classifyGoAway`
is a pure exhaustive map over the frozen 3-value union: the graceful reasons **`operatorDrain` and `shutdown`
both → `'drain'`** (finish in-flight, reconnect elsewhere — EXPLORE §3 node drain/shutdown) and **`revoked` →
`'revoked'`** (immediate teardown, no reconnect). An **unknown numeric code** never yields a valid
`GoAwayReason` — `decodeGoAwayReason` throws — and T14 **fails closed: treats it as `'revoked'`** (a default
that guesses "drain" is exactly the INV12-defeating failure). No stub/default mapping is permitted.
- **TDD**: RED — a §4.1 `GOAWAY` decoding (via `decodeGoAwayReason`) to `'revoked'` → **immediate** teardown
of all streams + tunnel and `isRevoked()` becomes true so T10 does **not** reconnect; an `'operatorDrain'`
**or** `'shutdown'` GOAWAY → graceful (finish in-flight, reconnect elsewhere); an **unknown/unmapped** reason
code (`decodeGoAwayReason` throws) → **fail-closed: treated as `'revoked'`** (never silently as drain) +
typed error surfaced (no swallow). GREEN.
- **Test cases**: revoke → teardown within one tick (seconds-scale, INV12); the three frozen reasons map
correctly — **`operatorDrain`→drain, `shutdown`→drain, `revoked`→revoke** — distinguished **only** via the
frozen `GoAwayReason`/`decodeGoAwayReason` (a test asserts no bare integer literal for a reason appears in
`revocation.ts`, and that `classifyGoAway` is exhaustive over the 3-value union); **unknown reason code fails
closed to revoked** (`decodeGoAwayReason` throws, INV12 safety); post-revoke reconnect suppressed (asserts
the injected `isRevoked` returns true to T10's loop); operator-drain path reconnects elsewhere.
- **Security**: **INV12** (global + per-host revocation the agent honors within seconds; a revoked host
neither holds a tunnel nor reconnects; unknown reason codes fail closed to revoked). Distinguish
node-drain/shutdown (`operatorDrain`/`shutdown` → reconnect elsewhere, EXPLORE §3) from revoke (`revoked` →
stop) **only** through the frozen §4.1 3-value reason contract — never a locally-chosen mapping (cross-plan
drift = silent INV12 defeat).
---
### W4 · Agent-side E2E endpoint (v0.10) — consumes P4 `relay-e2e/`
#### T15 · Host E2E endpoint (`host_hello` + seal/open splice) `[ ]` — v0.10
- **Owns**: `agent/src/e2e/hostEndpoint.ts`, `agent/test/hostEndpoint.test.ts`
- **Depends**: T3 (identity, for the transcript `sig`), T8 (`FrameTransform` seam), T19 (`ReplaySealer`,
same wave — authored before T15 wires it); **cross-plan HARD PRE-W4 GATE** — **P4 `relay-e2e/`** must FIRST
freeze BOTH (a) `sealFrame`/`openFrame` + the `createHostHandshake` host-handshake wiring AND (b) the
**injected device-auth-proof verifier** — P5-owned `verifyDeviceProof(proof, { clientEphPub, clientNonce })`
reaching T15 **through** P4's `createHostHandshake` (FIX 6b, **not** a direct `relay-e2e` import) — **plus a
published cross-plan test vector** (a valid proof + at least one forged/failing proof). Browser side is P6.
**Parallel-safe**: standalone file — **but see the blocking gate below: T15 does not START coding the
proof-abort path until (b) lands.**
> **BLOCKING GATE (was §6 open Q#2 — now a hard, named pre-W4 gate). The anti-MITM guarantee of the
> entire authenticated-ECDH-through-the-relay design rests on `deviceAuthProof` verification (§4.4
> `ClientHello`).** T15 only *consumes* that verifier; its issuance+verification lives in **P5** and reaches
> T15 **injected through P4's `createHostHandshake`** (FIX 6b) — the agent never `import`s it from
> `relay-e2e`. Until P5's `verifyDeviceProof` (bound to `{ clientEphPub, clientNonce }`) is frozen, wired
> into P4's `createHostHandshake`, **and** the shared test vector is published, there is no definition of
> what "the proof fails" means. **T15 is BLOCKED, NOT DEGRADED**: it must **not** merge or ship against a
> stubbed / no-op / always-true proof check to unblock the wave. A stubbed verifier would let a relay that
> injects a forged `ClientHello` complete a full handshake and derive `DirectionalKeys` with the agent —
> **exactly the threat this locked decision defends against** — silently defeating E2E. If the gate is not
> yet met when T15 is dispatched, the builder returns `[!] BLOCKED` (never guesses a proof format). The
> orchestrator resolves it by landing P5's verifier (via P4's wiring) + the vector, then re-dispatches.
- **Contracts** — implements the **host side** of INDEX **§4.4** and plugs a real `FrameTransform` into T8
(replacing `identityTransform`). It **imports** P4's crypto **and drives P4's host-handshake wiring** (the
device-auth-proof verifier reaches it **injected**, FIX 6b); it **re-declares nothing** from §4.4:
```ts
// FIX 6b — verifier is P5-owned and injected via P4's createHostHandshake; NEVER `import { verifyDeviceAuthProof } from 'relay-e2e'`.
import { sealFrame, openFrame, createHostHandshake } from 'relay-e2e' // P4 — frozen §4.4 primitives + host-handshake wiring
import { createE2ESession } from 'relay-contracts'
import type {
ClientHello, HostHello, HandshakeResult, DirectionalKeys, E2ESession, AeadKey,
} from 'relay-contracts' // §4.4 FROZEN shapes — cited verbatim, no local redefinition
// Host-side device-proof verifier — INJECTED (P5 issues+verifies; P4 wires it), bound to { clientEphPub, clientNonce } (FIX 6b):
export type VerifyDeviceProof =
(proof: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }) => Promise<boolean>
// Host side of §4.4: receive ClientHello (relay DATA) → verify proof FIRST → reply HostHello → derive DirectionalKeys.
// MITM defense: makeHostHello drives createHostHandshake({ verifyDeviceProof, identity: id }), which calls the injected
// verifier bound to { clientEphPub, clientNonce } FIRST and ABORTS (throws MitmAbortError, derives NO keys) on failure —
// there is no code path that skips this call.
export function makeHostHello(
clientHello: ClientHello, id: AgentIdentity, verifyDeviceProof: VerifyDeviceProof,
): Promise<{ hello: HostHello; result: HandshakeResult }>
// FIX 2 — returns HandshakeResult{ keys: DirectionalKeys{c2h,h2c}, aead, transcript }; NO single sessionKey/CryptoKey.
// HostHello.sig = id.sign(transcript); HostHello.enrollFpr = id.enrollFpr (§4.4 — browser pins it, TOFU)
export function createE2ETransform(
id: AgentIdentity, verifyDeviceProof: VerifyDeviceProof, replay: ReplaySealer, // replay = T19 K_content sealer (FIX 3)
): FrameTransform
// per stream: absorb ClientHello → (verify proof → else abort) → emit HostHello →
// session = createE2ESession('host', result) // host seals with h2c, opens with c2h (§4.4)
// thereafter: inbound = session.open(dataPayload); outbound(LIVE) = session.seal(plain) [ephemeral h2c];
// AND every replay-bound output is ALSO sealed via replay.seal(plain) [recoverable K_content, T19] — DISTINCT from live frames (FIX 3).
// Synchronous AeadKey seal/open; monotonic seq per direction (§4.4 / INV13).
```
**Boundary**: after `openFrame` decrypts, the bytes are handed to the loopback WS **as opaque bytes** —
the agent still **parses no terminal semantics** (INV11). Plaintext exists only host-local.
- **TDD (RED→GREEN)**:
- [ ] RED: `makeHostHello` produces a `HostHello` whose `sig` verifies under `enrollFpr` and whose ECDH
yields a `HandshakeResult` whose **`keys: DirectionalKeys{ c2h, h2c }`** match what the P4 client-side
test vector computes (§4.4 — direction-split, **no single `sessionKey`/`CryptoKey`**).
- [ ] RED (**anti-MITM, gated on the frozen verifier + vector**): using **P4's published forged-proof
vector**, a `ClientHello` whose `deviceAuthProof` fails the injected `verifyDeviceProof` → **ABORT**
(`MitmAbortError`, **no `DirectionalKeys` derived, no `HostHello` emitted**); likewise a transcript
mismatch → ABORT. This test is written against P4's real vector, **not** a local stand-in; it is the
load-bearing MITM test.
- [ ] RED (**no-stub CI guard, INV2 anti-MITM**): a static/CI guard fails the build if `makeHostHello`/
`createE2ETransform` can reach key derivation **without** calling the real (non-stub) injected
`verifyDeviceProof` — e.g. assert `hostEndpoint.ts` does **NOT** import any verifier from `relay-e2e`
(the verifier arrives only via the `verifyDeviceProof` param wired through P4's `createHostHandshake`,
FIX 6b), and a mutation test that replaces the injected verifier with `async () => true` makes the MITM
test FAIL. This makes "shipped against a stubbed proof check" an automatic red build.
- [ ] RED (**INV2**): given a stream's DATA frames, assert the **relay-visible** payload is the §4.4
`E2EEnvelope` (ciphertext+nonce+tag+seq) — a known plaintext marker fed at the loopback never
appears in any DATA payload bytes.
- [ ] RED (**INV13**): `openFrame` rejects a **replayed** envelope (dup seq), a **reordered** seq, and a
**bit-flipped** ciphertext (AEAD tag fail) → the stream is torn down, not silently dropped-through.
- [ ] RED (**INV11**): even post-decrypt, no ANSI/xterm import; decrypted bytes forwarded opaque.
- [ ] GREEN by wiring P4 primitives.
- **Acceptance criteria (explicit — mirrored in T18):**
1. **T15 is BLOCKED, not degraded, until the proof-verification contract exists** — no merge against a
stub/no-op/always-true `verifyDeviceProof` (P5-owned, injected via P4's `createHostHandshake`, FIX 6b);
the forged-proof vector from P4 must be present and the abort test green.
2. The no-stub CI guard (mutation test above) is present and passing.
3. All INV2/INV13/INV11 tests above green.
- **Test cases**: handshake key-agreement (**`DirectionalKeys{c2h,h2c}`**) vs P4 vector; **forged-proof →
ABORT (no keys) using P4's forged vector**; **no-stub CI guard (verifier-mutation → MITM test fails)**;
envelope-only on the wire (INV2); replay/reorder/tamper rejection (INV13); multi-device — two independent
`ClientHello`s derive two independent `HandshakeResult`s over the authenticated channel (§4.4,
INV3-adjacent); passthrough remains opaque (INV11).
- **Security**: **INV2** (relay sees only ciphertext), **INV13** (per-message nonce + monotonic seq),
**INV11** (opaque even after decrypt), **enrollment-key-bound + device-auth-proof-gated handshake**
(anti-MITM by a malicious relay — the guarantee is only real once the injected `verifyDeviceProof` is P5's
real verifier wired via P4's `createHostHandshake`, never a stub; enforced by the no-stub CI guard).
#### T19 · Replay-frame sealer (recoverable `K_content`) `[ ]` — v0.10 (FIX 3)
- **Owns**: `agent/src/e2e/replaySeal.ts`, `agent/test/replaySeal.test.ts`
- **Depends**: T3 (`Keystore.loadContentSecret`), T2 (config); **cross-plan**: `relay-contracts/` §4.4 replay
surface (`ReplayKeyParams`, `deriveContentKey`, `sealReplayFrame`, `REPLAY_KDF_INFO`) + P4 `relay-e2e/`
impls. **Wired INTO T15's `createE2ETransform`** (same wave — authored before T15 consumes it).
**Parallel-safe**: standalone file (no edge back to T15).
- **Rationale (FIX 3 — recoverable replay under E2E).** Live host→client frames are sealed with the
**ephemeral** `DirectionalKeys.h2c` (forward-secret — gone after reconnect). But the "refresh the page and
the Claude session is still there" guarantee (EXPLORE §4c) needs the ring-buffer/preview ciphertext to be
**recoverable** after a reload — so every replay-bound output is ALSO sealed under the **host-scoped
recoverable** `K_content = deriveContentKey({ hostContentSecret, sessionId, alg })`, **DISTINCT** from the
live `h2c` frame. The browser re-derives the identical `K_content` (via P5) and calls `openReplayCiphertext`
(P6 T9). This is the single agent-side consumer of the FIX 3 recoverable key (closes the "ship as
unconsumed library code" gap noted in RECONCILE FIX 3a).
- **Contracts** (imports the FROZEN §4.4 replay surface from `relay-contracts/`; re-declares nothing):
```ts
import { deriveContentKey, sealReplayFrame } from 'relay-e2e' // P4 impls of the §4.4 replay surface
import type { ReplayKeyParams, AeadKey, E2EEnvelope, AeadAlg } from 'relay-contracts' // §4.4 FROZEN shapes
export interface ReplaySealer {
seal(plaintext: Uint8Array): E2EEnvelope // K_content seal, monotonic seq per session (INV13); NOT the live h2c frame
}
// K_content derived once per (host, session): deriveContentKey({ hostContentSecret, sessionId, alg }).
export function createReplaySealer(hostContentSecret: Uint8Array, sessionId: string, alg: AeadAlg): ReplaySealer
```
`hostContentSecret` comes from `Keystore.loadContentSecret()` (T3, unwrapped at T4) — **never** the
ephemeral key, **never** logged, **never** sent to the relay (INV2/INV9).
- **TDD (RED→GREEN)**:
- [ ] RED: `deriveContentKey` with the same `{ hostContentSecret, sessionId, alg }` is **deterministic**
(a reload re-derives the identical `K_content`); a different `sessionId` → a different key (per-session).
- [ ] RED: `seal` emits a §4.4 `E2EEnvelope` with **monotonic seq** (INV13); a known plaintext marker
never appears in the envelope bytes (INV2).
- [ ] RED: for the same plaintext, the replay seal is **distinct** from the live `session.seal` (h2c) output
(different key ⇒ different ciphertext) — the two seals are not interchangeable (FIX 3).
- [ ] RED (cross-plan vector): a `sealReplayFrame` envelope round-trips through P6's `openReplayCiphertext`
against a shared test vector (same `hostContentSecret`/`sessionId`).
- [ ] GREEN by wiring P4 impls.
- **Test cases**: deterministic `K_content` re-derivation; per-session key separation; monotonic seq;
envelope-only (INV2); replay-seal ≠ live-seal; P6 round-trip vector.
- **Security**: **INV2** (replay ciphertext still opaque to the relay), **INV13** (monotonic seq on replay
frames too), **INV5/INV9** (`hostContentSecret` at rest `0600`, never logged/egressed). The recoverable
`K_content` is **host-scoped + per-host revocable** (P3 can invalidate the wrap, INDEX §4.5) — **not** an
account-wide secret.
---
### W5 · Distribution + acceptance
#### T16 · Static-binary build `[ ]` — v0.9 (EXPLORE §6 distribution rank 2)
- **Owns**: `agent/src/dist/buildBinary.ts`, `agent/test/buildBinary.test.ts`
- **Depends**: T5 (CLI). **Parallel-safe**: with T17.
- **Contracts**: `export function buildBinaryConfig(target: BinaryTarget): BuildSpec` where
`BinaryTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64'` (string-literal union);
produces a `bun --compile` spec (fallback `pkg`) for a one-`curl | sh` install. `npx web-terminal-agent`
remains the MVP path (EXPLORE §6 rank 1 — "customers already `npm install`").
- **TDD**: RED — build spec targets each supported triple; entry = `cli.ts`; the binary bundle **excludes**
any dev/test dep and any terminal parser (INV11 re-checked at package boundary). GREEN.
- **Test cases**: per-target spec; entrypoint correct; no ANSI-parser in the bundle graph; `npx` path
documented.
- **Security**: EXPLORE §4d — the agent is a **signed** artifact (a compromised update = same blast radius,
so the channel is signed/pinned; **signing itself is P5/CI-owned**, this task exposes the hook).
#### T17 · Service install + Origin touch-point `[ ]` — v0.9 (EXPLORE §6)
- **Owns**: `agent/src/service/install.ts`, `agent/src/service/launchd.ts`,
`agent/src/service/systemd.ts`, `agent/src/service/originConfig.ts`,
`agent/test/install.test.ts`, `agent/test/originConfig.test.ts`
- **Depends**: T2, T5. **Parallel-safe**: with T16.
- **Contracts**:
```ts
export type ServicePlatform = 'launchd' | 'systemd'
export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null
export function installService(cfg: AgentConfig, platform: ServicePlatform): Promise<void> // writes+loads unit
export function uninstallService(platform: ServicePlatform): Promise<void>
// the ONE base-app touch-point (INDEX §0, EXPLORE §3): add the subdomain to ALLOWED_ORIGINS as CONFIG
export function ensureAllowedOrigin(baseAppEnvPath: string, subdomain: string, domain: string): void
```
`ensureAllowedOrigin` **appends** `https://<subdomain>.term.<domain>` to the base app's
`ALLOWED_ORIGINS` env (idempotent) — **no `src/` code edit** (EXPLORE §3 "Zero code change"). The service
runs the agent as the **logged-in user, not root** (EXPLORE §4d — least privilege).
- **TDD**: RED — launchd plist / systemd unit contain the right `ExecStart` (`web-terminal-agent run`),
restart-on-failure, and **run-as-user (never root)**; `ensureAllowedOrigin` is idempotent and never
removes an existing origin (does not weaken the Origin check — EXPLORE §3). GREEN with a mock FS.
- **Test cases**: plist/unit generation; run-as-user assertion (negative: refuses to install as root);
idempotent origin append; existing origins preserved; uninstall unloads cleanly.
- **Security**: least-privilege service (INV-adjacent, EXPLORE §4d); the Origin touch-point **augments,
never weakens**, CSWSH protection (EXPLORE §3, INV15/INV6 spirit); no secrets written to the unit file
(INV9 — key/cert stay in the keystore, referenced by path).
#### T18 · Acceptance / verification harness `[ ]` — spans phases
- **Owns**: `agent/test/acceptance/*.test.ts` (agent-local integration), `agent/test/security/*.test.ts`
- **Depends**: all above; **cross-plan** integration with P1/P3/P4 lives in their acceptance suites (this
is the agent-local slice — do not edit other packages).
- **Steps**:
- [ ] **INV tripwires** (one per owned INV, §1): INV2 marker-never-on-wire; INV4 no-private-key-egress;
INV5 keystore-0600 + code-not-at-rest; INV9 no-secret-in-logs; INV11 no-parser-in-bundle;
INV12 revoke→teardown+no-reconnect; INV14 mTLS-required + rotation.
- [ ] **INV1 defense-in-depth tripwire (T8)**: an OPEN whose `MuxOpen.subdomain !== cfg.subdomain` is RST
and **never dialed** — the agent's last-line cross-tenant boundary check.
- [ ] **INV12 GOAWAY-reason tripwire (T14)**: a `'revoked'` GOAWAY (decoded via the frozen
`decodeGoAwayReason`) tears down + suppresses reconnect; `'operatorDrain'` **and** `'shutdown'`
reconnect elsewhere; an **unknown reason code fails closed to revoked**; assert no bare integer reason
literal in `revocation.ts` (cross-plan-drift guard).
- [ ] **INV2 anti-MITM no-stub gate (T15)**: with P4's **forged-proof vector**, a failing `deviceAuthProof`
→ ABORT (no `DirectionalKeys`). The **verifier-mutation guard** — replacing the injected
`verifyDeviceProof` (P5-owned, wired via P4's `createHostHandshake`, FIX 6b) with a no-op makes this
test FAIL — must be present and passing (proves T15 cannot ship against a stub, and that no verifier is
imported directly from `relay-e2e`).
- [ ] **FIX 3 recoverable-replay tripwire (T19)**: a replay-bound output sealed with `sealReplayFrame`
(`K_content`) round-trips through P6's `openReplayCiphertext`, is **ciphertext to the relay** (INV2),
and is **distinct** from the live `h2c` frame for the same plaintext.
- [ ] **Café-demo agent slice**: pair (mock P3) → dial (mock P1) → OPEN → loopback splice against a
stub `ws://127.0.0.1:3000` echo server → bytes flow both ways (EXPLORE §5 demo, agent portion).
- [ ] Coverage ≥ 80% across `agent/src/**`.
- **Accept**: all INV tripwires green (incl. the T8 subdomain boundary, T14 GOAWAY-reason fail-closed, the
T15 no-stub anti-MITM guard, and the T19 recoverable-replay round-trip); café-demo slice green; coverage
gate met. **T15 must not be marked accepted while its injected-`verifyDeviceProof` gate (Q#2) is
unresolved** (T14's `GoAwayReason` contract is frozen in INDEX §4.1 — no longer a gate).
---
## 4. SECURITY (per-plan summary)
The host-agent is a **CIPHERTEXT-shuttle endpoint on the customer's own machine** (INDEX §0). Its security
posture:
1. **Private key never leaves the host (INV4)** — Ed25519 generated locally (T3); only the **public** key
+ a CSR are transmitted (T4); mTLS proves possession without sending the key (T12). A relay DB dump
cannot impersonate a host (EXPLORE §4a.1 "a per-host public key in your DB is useless to a thief").
2. **No shared secrets on the wire (INV4/INV14)** — the tunnel is authenticated by **mTLS**, not a bearer
token; certs are **short-lived and auto-rotated** (T13); revocation = stop renewing + tear down (T14,
INV12), where a **`revoked`** GOAWAY is distinguished from the graceful **`operatorDrain`/`shutdown`**
GOAWAY reasons only via the **frozen** 3-value `relay-contracts` `GoAwayReason`/`decodeGoAwayReason` (never
a locally-invented numeric mapping — a drift there would silently downgrade a revoke to a reconnect;
unknown reason ⇒ fail-closed to revoked). The
v0.8 shared `agentToken` (T6) is an explicit, retired stepping-stone.
3. **Relay sees only ciphertext (INV2)** — from v0.10, every byte entering the tunnel is a §4.4
`E2EEnvelope` sealed at the agent: **live** frames under the ephemeral `DirectionalKeys.h2c` (T15),
**replay-bound** frames under the recoverable `K_content` via `sealReplayFrame` (T19, FIX 3) — both
ciphertext to the relay. The handshake yields **`DirectionalKeys{c2h,h2c}`** (FIX 2, no single
`sessionKey`) and is **enrollment-key-bound AND device-auth-proof-gated** so a malicious relay cannot MITM
(browser pins `enroll_fpr`; the agent runs P4's `createHostHandshake` with the **injected, P5-owned**
`verifyDeviceProof` bound to `{clientEphPub, clientNonce}` (FIX 6b) and ABORTS on a forged `ClientHello`,
deriving no keys). **This guarantee is only real once that injected `verifyDeviceProof` is P5's real
verifier (wired via P4), never imported from `relay-e2e` — T15 is BLOCKED, not degraded, against any stub,
enforced by a no-stub CI mutation guard (§6 Q#2).** Anti-replay/injection via per-message nonce + monotonic
seq (INV13).
3b. **Agent-side cross-tenant boundary (INV1 defense-in-depth)** — although tenant authz is P1/P5's job and
there is one physical tunnel per host, `handleOpen` (T8) still refuses to dial any `OPEN` whose
`MuxOpen.subdomain` ≠ the agent's enrolled `subdomain`, RST-ing it and audit-logging the mismatch. Cheap,
load-bearing, and the last line against a cross-wired relay frame — consistent with "don't fully trust
the relay."
4. **No terminal parsing anywhere in the agent (INV11)** — even after E2E decrypt, bytes are handed to the
loopback WS opaque; the package has **no** ANSI/xterm dependency (enforced by a bundle-graph tripwire,
T1/T16).
5. **No plaintext/secret at rest (INV5)** — key/cert `0600`; raw pairing code never persisted or logged;
no shell bytes buffered (pure shuttle, per-connection allocation, **no global buffers** — closes the
EXPLORE §4b cross-tenant-buffer-bleed failure mode).
6. **Secrets validated at startup, never logged (INV9)** — Zod-validated config; fail-fast on missing key
material; a redacting logger; **no `console.log`**.
7. **Least privilege (EXPLORE §4d)** — the installed service runs as the **logged-in user, not root**
(T17); the loopback target is **loopback-only** (anti-SSRF, T2/T8).
8. **Origin check augmented, never weakened (EXPLORE §3)** — the agent replays the real browser `Origin`
(§4.1 `MuxOpen.originHeader`) to the base app and only **adds** the subdomain to `ALLOWED_ORIGINS` at
install (T17); CSWSH protection stays meaningful end-to-end.
**Out-of-scope security owned elsewhere (do not implement here):** capability-token verification, human
auth/step-up, the CI cross-tenant tripwire, the revocation *policy/DB*, the mTLS *CA* → **P5/P3**; the E2E
crypto *primitives* → **P4**; the relay-side isolation enforcement → **P1/P5**.
---
## 5. VERIFICATION
```bash
# Unit + security tripwires (agent-local)
npm --prefix agent run typecheck # strict TS; imports relay-contracts / relay-e2e read-only
npm --prefix agent test -- config logger # INV9 boundary + redaction
npm --prefix agent test -- identity keystore # INV4 (no private-key egress) + INV5 (0600)
npm --prefix agent test -- pair csr # §4.5 redemption; code-not-at-rest; single-use respect
npm --prefix agent test -- tunnel streamRouter loopback # §4.1 demux, RST-on-illegal, INV11 opaque, 2-stream isolation
npm --prefix agent test -- heartbeat backoff flowControl # 15s PING/PONG, 1/2/4…cap-30s, per-stream fairness
npm --prefix agent test -- dial rotation revocation # INV14 mTLS + rotation, INV12 revoke→teardown+no-reconnect
npm --prefix agent test -- hostEndpoint replaySeal # §4.4 handshake→DirectionalKeys vs P4 vector, INV2 envelope-only, INV13 replay/reorder/tamper; recoverable K_content seal (FIX 3)
npm --prefix agent test -- install originConfig # run-as-user (not root), idempotent ALLOWED_ORIGINS append
npm --prefix agent test -- acceptance security # all owned-INV tripwires + café-demo agent slice
npm --prefix agent run test:coverage # ≥ 80% across agent/src/**
# Manual café-demo (cross-plan, once P1+P3 exist): on a host behind NAT
npx web-terminal-agent pair ABCD-1234 # generates Ed25519 locally, redeems code, installs service
web-terminal-agent status # prints host_id/subdomain/online — NO key/cert material
# then open https://<subdomain>.term.<domain> on a phone → land in the host shell (P6 verifies the browser end)
```
**Key manual checks:** (1) after `pair`, the private key file is `0600` and the request that hit `/enroll`
carried **only** pubkey+CSR (INV4); (2) `status` leaks no secrets (INV9); (3) killing the relay node mid-
session → the agent backs off (1/2/4…) and reconnects, **PTY survives** on the host (EXPLORE §3, INV7 on
the relay side); (4) revoking the host → its tunnel drops within seconds and does not reconnect (INV12);
(5) with E2E (v0.10) a plaintext marker typed in the shell never appears in captured relay traffic (INV2).
---
## 6. Open questions (for the orchestrator — do NOT guess; return `[!] BLOCKED` if hit mid-task)
1. **CSR format for an Ed25519 SPIFFE-style cert** — §4.5 says the agent sends a `csr`, but the exact
profile (PKCS#10 vs a SPIFFE SVID request; SAN = the subdomain vs a SPIFFE URI) is set by **P3's CA**.
T4/T13 must consume P3's chosen shape; if P3 hasn't frozen it, T4 is BLOCKED on P3.
2. **[HARD PRE-W4 GATE — ownership RESOLVED by FIX 6b; the freeze/vector gate remains] `deviceAuthProof`
verifier (§4.4 `ClientHello`).** Ownership is now settled: **P5 issues+verifies** the proof
(`verifyDeviceProof(proof, { clientEphPub, clientNonce })`); **P4 wires it** into `createHostHandshake`;
**P2/T15 consumes it INJECTED through P4** — never `import { verifyDeviceAuthProof } from 'relay-e2e'` (the
old import was wrong, INDEX §6b). What remains a hard gate is timing: because the entire anti-MITM
guarantee of authenticated-ECDH-through-the-relay rests on this check, **T15 is BLOCKED (not degraded)
until P5's verifier is frozen, wired into P4's `createHostHandshake`, AND a cross-plan test vector (a valid
proof + ≥1 forged/failing proof) is published.** T15 must never ship against a stub/no-op/always-true
verifier (that would let a forged `ClientHello` complete a handshake and derive `DirectionalKeys` — the
exact threat). A no-stub CI guard (verifier-mutation test) enforces this at the build. If unmet at
dispatch, the builder returns `[!] BLOCKED`; the orchestrator lands the verifier+vector, then re-dispatches
T15.
3. **[RESOLVED — §4.1 addendum frozen] GOAWAY reason-code semantics.** INDEX §4.1 (line 225) now freezes the
mapping as `GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'` (string-literal union) +
`decodeGoAwayReason` (`1→operatorDrain · 2→revoked · 3→shutdown`, else throws) — the **same** codes P1
`drain.ts` `DRAIN_REASON` (`operatorDrain:1, revoked:2, shutdown:3`) and P5 revocation (FIX 4) emit. T14
imports the union + decoder **verbatim** from `relay-contracts/` (no local redefinition), maps
`operatorDrain`/`shutdown` → drain (reconnect elsewhere) and `revoked` → revoke (stop), and **fails closed
to `revoked` on any unknown numeric code** (`decodeGoAwayReason` throws). This closes the earlier drift
where P2 assumed a 2-value `'drain' | 'revoked'` union the frozen decoder never emits (a genuine revoke
misread as a drain would silently defeat INV12 with both plans' unit tests still green). **T14 is no longer
BLOCKED on this** — the contract is frozen in §4 per this INDEX's rule that shared semantics live there.
4. **v0.8 `agentToken` transport auth vs frp's own token** — T6 wraps `frpc`; whether the shared token is
frp's `auth.token` or a P3-issued value affects the P3 flat-table schema (§4.2 note). Confirm with P3
before wiring T6's `frpc.toml`.
5. **Cert-rotation renewal endpoint contract** — T13 posts a renewal CSR; the URL/auth (mTLS with the
*current* cert? a short renewal token?) is **P3**. Blocked on P3 freezing the renewal route.
6. **Multi-device key distribution channel (§4.4)** — "authenticated (account-derived) channel, not a
plaintext QR": the agent side (T15) derives per-device keys via per-stream handshakes; whether any
agent-side coordination is needed for the *same* session across devices is a **P4** design point —
confirm the agent has no extra responsibility beyond per-stream handshake.
```