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.
76 KiB
P2 — Host Agent (agent/) — Implementation Plan
Plan of the 6 (see
PLAN_RELAY_INDEX.md— the coordination point / analog ofsrc/types.ts). This plan owns theagent/package only. Source of intent:EXPLORE_RELAY_SERVICE.md§0 LOCKED DECISIONS (AUTHORITATIVE, CLOSED) + §3 host-agent lifecycle + §6 distribution. Style:PLAN.md/PLAN_VOICE_COMMANDS.md— stable task IDs grouped into dependency waves, anOwns: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 agentimports them read-only fromrelay-contracts/.PROGRESS_LOG.mdis 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"):
- Enrollment / pairing redemption — INDEX §4.5:
npx web-terminal-agent pair ABCD-1234generates an Ed25519 keypair LOCALLY (private key never leaves the host, INV4), redeems a single-use, short-TTL pairing code, receives the frozen §4.5EnrollResult{ host_id, subdomain, cert, caChain, hostContentSecret }(FIX 3 — the last field wrapped to the agent identity, unwrapped + stored locally for recoverable replay). - Outbound dial + mTLS —
wss://relay/agenton 443 (CGNAT-friendly, EXPLORE §3), mutually authenticated with the per-host key + short-lived auto-rotating cert (INV14). - Registration — relay writes
route:{host_id}in Redis (P3 side); the agent proves key possession and holds the link. - 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 fromrelay-contracts/, never re-implemented). - Forwarding logical streams to the UNCHANGED web-terminal at
ws://127.0.0.1:3000— oneOPEN⇒ one fresh loopback socket, replaying the real browserOrigin(§4.1MuxOpen.originHeader). - Agent-side E2E endpoint — INDEX §4.4: the host side of the handshake (
host_hello, transcriptsigunder the enrollment key) yielding aHandshakeResultwith direction-splitDirectionalKeys(c2h/h2c) (FIX 2 — no singlesessionKey: CryptoKey), driven by P4'screateHostHandshakewith the injected device-auth-proof verifier (FIX 6b); per-stream synchronoussealFrame/openFrameoverAeadKey; and — distinct from liveh2cframes — sealing every replay-bound host→client output under the recoverableK_contentviasealReplayFrame(FIX 3). The agent wires the P4relay-e2e/crypto core (imported, not written here). The agent is one E2E endpoint; plaintext exists only on the customer's own machine. - 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. - 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) → P4relay-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/andpublic/— byte-for-byte unchanged. The single base-app touch-point (ALLOWED_ORIGINSgains the subdomain) is applied by the agent installer at install time as a config/env write, not a code edit (INDEX §0, EXPLORE §3 "one base-app touch-point").
1. SECURITY INVARIANTS this plan enforces (INDEX §3)
Per INDEX §3, this plan lists the invariants it owns and ships a test per owned invariant:
| INV | How agent/ enforces it |
Owning task(s) |
|---|---|---|
| INV1 — cross-tenant isolation (agent-side defense-in-depth; primary enforcement is P1/P5) | handleOpen compares MuxOpen.subdomain (§4.1) against the agent's own enrolled AgentConfig.subdomain before dialing loopback; any mismatch ⇒ RST the stream, never dial, audit-log (metadata-only, INV10). The one place the agent can catch a relay that cross-wires host B's OPEN into host A's tunnel. |
T8 |
| INV2 — relay handles only ciphertext | Agent seals output → §4.1 DATA before it enters the tunnel; the relay only ever sees §4.4 E2EEnvelope bytes. Live frames use the ephemeral DirectionalKeys.h2c; replay-bound output is ALSO sealed under the recoverable K_content (sealReplayFrame, FIX 3) — still ciphertext to the relay. Plaintext lives only host-local (loopback ↔ agent). Handshake is device-auth-proof-gated (T15 aborts on a forged ClientHello, no key derived). |
T15, T19 |
| INV4 — no shared secrets; per-host asymmetric identity; DB stores only public keys | Ed25519 keypair generated locally; private key persisted 0600, never transmitted (only pubkey + CSR leave); mTLS proves possession without sending the key. |
T3, T4, T12 |
| INV5 — no plaintext/secret at rest (agent-local) | Private key + cert stored with restrictive perms; raw pairing code never written to disk; no plaintext shell bytes buffered (pure shuttle). | T3, T4, T8 |
| INV9 — secrets in env/secret-manager, validated at startup, never logged | Config validated with Zod at boot; fail-fast on missing/invalid key material; structured logger redacts key/cert/code fields; no console.log. |
T2, T18 |
| INV11 — no terminal parsing in the agent (ciphertext/byte-shuttle discipline) | The agent moves opaque bytes; imports no xterm/ANSI parser; even after E2E openFrame the decrypted bytes are treated as opaque to the loopback WS. |
T7, T8, T15 |
| INV12 — fast revocation kills live tunnels in seconds | On a 'revoked' GOAWAY (distinguished from the graceful 'operatorDrain'/'shutdown' reasons via the frozen 3-value relay-contracts GoAwayReason/decodeGoAwayReason, never a local numeric guess; unknown reason ⇒ fail-closed to revoked) or cert-renewal refusal, the agent tears down all streams + the tunnel immediately and stops reconnect for a revoked host. |
T13, T14 |
| INV14 — mTLS with SPIFFE-style short-lived, auto-rotating certs | Dial uses the client cert; a rotation timer renews before expiry via the P3 renewal endpoint; expired/absent cert ⇒ no dial. | T12, T13 |
Cross-cutting discipline (INDEX §0, coding-style): immutable snapshots (tunnel/stream state swapped
atomically, never mutated in place); many small files (200–400 lines, 800 hard max); high cohesion / low
coupling; TypeScript, camelCase fns / PascalCase types / string-literal unions over enums; Zod
validation at every boundary (pairing response, MuxOpen, config); explicit error handling (no
swallowed errors); no console.log; TDD 80%+ coverage.
2. Files & ownership (the agent/ package — disjoint from all other plans)
agent/
package.json T1 scaffold (bin: web-terminal-agent)
tsconfig.json T1
vitest.config.ts T1
src/
cli.ts T5 arg parse → pair | run | status | install | uninstall
config/agentConfig.ts T2 AgentConfig Zod schema + load/validate + paths
transport/seams.ts T2 W0 shared seam types: WsLike, RevocationState, RevokeReason (cycle breaker)
keys/identity.ts T3 Ed25519 keygen, load, fingerprint (enroll_fpr, §4.2)
keys/keystore.ts T3 0600 on-disk read/write of key + cert (INV5)
enroll/pair.ts T4 §4.5 REDEEM/RETURN client; CSR build
enroll/csr.ts T4 PKCS#10 CSR from the Ed25519 key
transport/dial.ts T12 outbound mTLS wss dial (client cert)
transport/tunnel.ts T7 holds one §4.1 tunnel: demux loop, encode, GOAWAY
transport/streamRouter.ts T8 streamId → loopback socket map; OPEN/DATA/CLOSE/RST
transport/loopback.ts T8 dial ws://127.0.0.1:3000<path>, replay Origin, splice
transport/heartbeat.ts T9 PING/PONG every 15s; miss ⇒ dead
transport/backoff.ts T10 1/2/4…cap-30s + jitter reconnection policy
transport/flowControl.ts T11 per-stream credit (§4.1 WINDOW_UPDATE) consumer
transport/frpScaffold.ts T6 v0.8 frpc-wrap stepping-stone (retired at v0.9)
e2e/hostEndpoint.ts T15 §4.4 host side: host_hello + seal/open splice transform
e2e/replaySeal.ts T19 §4.4 replay surface: K_content seal of replay-bound output (FIX 3)
certs/rotation.ts T13 short-lived cert renewal timer (INV14)
lifecycle/revocation.ts T14 GOAWAY/revoke → teardown, refuse reconnect (INV12)
service/install.ts T17 dispatch by platform
service/launchd.ts T17 macOS plist writer + load/unload
service/systemd.ts T17 Linux unit writer + enable/start
service/originConfig.ts T17 writes ALLOWED_ORIGINS touch-point (base-app config)
dist/buildBinary.ts T16 bun --compile config + npx wrapper
log/logger.ts T2 structured redacting logger (no console.log, INV9)
test/
<mirrors src, one *.test.ts per module>
fixtures/ shared test vectors (pairing resp, MuxOpen frames)
Imports (read-only) from other plans' frozen artifacts — never redeclared here:
relay-contracts/(INDEX §4):encodeMuxFrame/decodeMuxFrame,MuxFrameHeader,MuxOpen,HostRecord,HostStatus, pairing Zod schemas + §4.5EnrollResult(now carryinghostContentSecret, FIX 3); the frozen §4.4 E2E surface cited verbatim —AeadAlg,AeadKey(opaque wrapper, notCryptoKey),ClientHello,HostHello,E2EEnvelope,DirectionalKeys(c2h/h2c),HandshakeResult,E2ESession,createE2ESession, and the synchronoussealFrame/openFrame; the §4.4 replay surfaceReplayKeyParams/deriveContentKey/sealReplayFrame/REPLAY_KDF_INFO(FIX 3); and the frozen §4.1GoAwayReason('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 wiringcreateHostHandshake({ verifyDeviceProof, … }). FIX 6b: the device-auth-proof verifier is P5-owned and reaches the agent injected through P4'screateHostHandshake— the agent does NOTimport { verifyDeviceAuthProof } from 'relay-e2e'(that old T15 import was wrong). The verifier is bound to{ clientEphPub, clientNonce }(§4.4ClientHello), unified across P2/P4/P5. (agent/e2e/hostEndpoint.ts+agent/e2e/replaySeal.tswire 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.jsonwith"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,noEmitoff, outDirdist); path alias torelay-contracts.vitest.config.tswithcoverage.include: ['src/**'], node environment.- smoke test:
test/scaffold.test.tsassertsrelay-contractsimports resolve and no transitive dep matches/xterm|ansi|vt100/(INV11 tripwire).
- Accept:
npm --prefix agent testruns (smoke green);tsc --noEmitclean. - Security: INV11 dependency tripwire lives here.
T2 · Config + redacting logger + shared seams [ ] — v0.8
- Owns:
agent/src/config/agentConfig.ts,agent/src/log/logger.ts,agent/src/transport/seams.ts,agent/test/agentConfig.test.ts,agent/test/logger.test.ts,agent/test/seams.test.ts - Depends: T1. Parallel-safe: with T3.
- Contracts:
export interface AgentConfig { readonly relayUrl: string // wss://relay.<domain>/agent — validated wss:// only readonly enrollUrl: string // https://<domain>/enroll — https:// only readonly stateDir: string // where key/cert live (default ~/.web-terminal-agent) readonly localTargetUrl: string // ws://127.0.0.1:3000 (the UNCHANGED web-terminal) readonly subdomain: string | null // filled after pairing readonly hostId: string | null // filled after pairing (§4.2) } export const AgentConfigSchema: ZodType<AgentConfig> // wss/https/ws enforced by refine export function loadAgentConfig(env: NodeJS.ProcessEnv, argv: Partial<AgentConfig>): AgentConfig // logger — redacts key/cert/code/token fields; no console.log export type LogLevel = 'debug' | 'info' | 'warn' | 'error' export function createLogger(level: LogLevel): { log(l: LogLevel, msg: string, meta?: Record<string, unknown>): void }agent/src/transport/seams.ts— W0 shared injection seams (DEPENDENCY-CYCLE BREAKER). These are intra-agent/seam types only (no cross-plan frozen contract lives here — those stay inrelay-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):Both// 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 }WsLikeandRevocationState/RevokeReasonare 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 (onlywss://);localTargetUrlnon-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.
- RED:
- 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;debugmeta with anoncekey passes through (not a secret); seams:seams.tsexportsWsLike,RevocationState,RevokeReasonand isimport-able with no runtime dependency (type-only file — a test asserts the compiled module has no side effects and nows/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:cryptogenerateKeyPairSync('ed25519'); no third-party crypto):export interface AgentIdentity { readonly publicKey: Uint8Array // Ed25519 raw pubkey → stored in host registry (§4.2 agent_pubkey) readonly enrollFpr: string // §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by browser E2E TOFU sign(message: Uint8Array): Uint8Array // uses private key IN-PROCESS; key never returned/serialized off-host } export function generateIdentity(): AgentIdentity // private key held in-memory only export function computeEnrollFpr(publicKey: Uint8Array): string // keystore — persists ONLY to stateDir with 0600 (INV4/INV5) export interface Keystore { saveIdentity(id: AgentIdentity): void // writes private key PEM 0600 + pubkey loadIdentity(): AgentIdentity | null saveCert(certPem: string, caChainPem: string): void // 0600 loadCert(): { certPem: string; caChainPem: string } | null saveContentSecret(secret: Uint8Array): void // 0600 — FIX 3: the §4.5 hostContentSecret, UNWRAPPED with the enrollment key loadContentSecret(): Uint8Array | null // input to §4.4 deriveContentKey (ReplayKeyParams); never logged/egressed } export function openKeystore(stateDir: string): Keystore - TDD (RED→GREEN):
- RED:
generateIdentity()twice → distinct pubkeys;sign/verify round-trips againstcrypto.verify. - RED:
computeEnrollFpris deterministic + matches the §4.2enroll_fprderivation P4/P6 pin. - RED:
saveIdentitythen 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 —
AgentIdentityexposes onlypublicKey,enrollFpr,sign(INV4). Grep test: no export/return of raw private-key bytes. - GREEN.
- RED:
- 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/loadContentSecretround-trip thehostContentSecretbytes, file mode0600, 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-checkenroll_fprderivation 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
/enrollendpoint (§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/:// §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:
redeemPairingCodesends{ code, agentPubkey, csr }; assert the private key is NOT in the request body (INV4) — only pubkey + CSR. - RED: raw
codeis never written tostateDirand never logged (INV5/INV9). - RED: malformed/HTTP-error
/enrollresponse → typedEnrollError(no swallow); response not matching the frozen §4.5 schema → reject (boundary validation). - RED (single-use): a
409 already-redeemedfrom P3 → surfaced asPairingCodeSpentError(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 stored0600viaKeystore.saveContentSecret(T3); assert the wrapped bytes are never persisted and the unwrapped secret is never logged (INV5/INV9). - GREEN.
- RED:
- Test cases: happy path returns the frozen §4.5 shape
{hostId, subdomain, cert, caChain, hostContentSecret}and keystore stores cert0600and the unwrappedhostContentSecret0600; body omits private key; expired-code (410) →PairingCodeExpiredError; spent-code (409) →PairingCodeSpentError; schema-mismatch response → rejected;enrollUrlnon-https → refused (T2 guard). v0.8 variant: when no Ed25519 flow is enabled,redeemPairingCodedegrades to presenting the sharedagentToken(still hashed server-side by P3) — behind an explicitEnrollMode = 'token' | 'ed25519'string-literal union; a test asserts'ed25519'is the default from v0.9. - Security: INV4 (only pubkey+CSR leave), INV5 (raw code not at rest), boundary validation on the enroll response (untrusted external data), single-use respect.
T5 · CLI entrypoint [ ] — v0.8 skeleton → extended each wave
- Owns:
agent/src/cli.ts,agent/test/cli.test.ts - Depends: T2, T3, T4 (and later T7/T13/T17 handlers). Parallel-safe: with T4/T6.
- Contracts:
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall' export interface CliArgs { readonly command: CliCommand; readonly code?: string; readonly flags: Readonly<Record<string, string | boolean>> } export function parseArgs(argv: readonly string[]): CliArgs export function runCli(args: CliArgs, deps: CliDeps): Promise<number> // returns process exit coderunClidispatches: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);statusprints no key/cert material (INV9); missingcodeonpair→ usage error. GREEN. - Test cases: arg parsing matrix;
pairhappy path calls redeem then install (mocked);statusredaction; unknown command exit code;runbefore 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
frpcas the agent", INDEX §1 v0.8): fastest path to the café demo; wrapsfrpc(child process) presenting the sharedagentToken, registeringsubdomain, forwarding to127.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:
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.tomlhaslocal_ip=127.0.0.1 local_port=3000,subdomain=<cfg>, tls enabled; child crash →onExitfires 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 assertsfrpScaffoldis not imported byrunonceEnrollMode==='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.tsWsLike), T3, dial (T12) at integration; cross-plan: P1 §4.1 codec (encodeMuxFrame/decodeMuxFrame) fromrelay-contracts/. Parallel-safe: with T8–T11 (disjoint files, shared frozen §4.1 interface). - Contracts (imports §4.1
MuxFrameHeader,MuxFrameType, codec — never re-declared; importsWsLike+ the frozenGoAwayReasonfromrelay-contracts/):The demux loop: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): TunneldecodeMuxFrameeach inbound frame → dispatch bytypetorouter(OPEN/DATA/ CLOSE/RST) orheartbeat(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 →
onFrameyields the decoded header+payload. - RED: DATA on an unknown
streamId→ agent emits aCLOSEwithflags.RSTfor that stream and the tunnel stays up (§4.1). - RED: INV11 — tunnel imports no ANSI/xterm parser; DATA payload passes through opaque.
- RED: a
GOAWAYframe → in-flight streams allowed to finish, no new OPEN accepted (drain). - GREEN.
- RED: feed a byte-exact §4.1 frame fixture →
- 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
MuxOpenwith the frozen §4.1 Zod schema fromrelay-contracts/):export interface StreamRouter { handleOpen(open: MuxOpen): void // one OPEN ⇒ one loopback socket — AFTER the subdomain boundary check handleData(streamId: number, payload: Uint8Array): void handleClose(streamId: number, rst: boolean): void activeStreamCount(): number } export function createStreamRouter(cfg: AgentConfig, tunnel: Tunnel, dialLoopback: DialLoopback, transform: FrameTransform): StreamRouter export type DialLoopback = (path: string, origin: string) => Promise<WsLike> // dials ws://127.0.0.1:3000<path>, sets header `Origin: <origin>` from §4.1 MuxOpen.originHeader export function dialLoopback(target: string): DialLoopback // FrameTransform: identity in v0.9 (plaintext passthrough); replaced by the E2E codec in v0.10 (T15) export interface FrameTransform { inbound(streamId: number, cipher: Uint8Array): Uint8Array | null // tunnel→local (decrypt in v0.10) outbound(streamId: number, plain: Uint8Array): Uint8Array // local→tunnel (encrypt in v0.10) openStream(streamId: number): void; closeStream(streamId: number): void } export const identityTransform: FrameTransform // v0.9: inbound/outbound = passthroughPer-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
OPENhere. 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 thinghandleOpendoes — before allocating any state or dialing loopback — is compare the frozenMuxOpen.subdomain(§4.1) against this agent's own enrolledAgentConfig.subdomain(§4.2, filled at pairing):// handleOpen — first statement, before any allocation or dial: if (open.subdomain !== cfg.subdomain) { // never proceed to dial; RST this stream and audit-log the mismatch (metadata only, INV10 — no payload) tunnel.send(rstHeaderFor(open.streamId), EMPTY) // §4.1 CLOSE + flags.RST logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId }) return }This is a one-field comparison; it closes an otherwise-silent cross-tenant-bleed vector (host B's OPEN spliced into host A's tunnel) at the single point in the whole system where the agent can detect it. It complements, never replaces, relay-side authz (INV1, owned by P1/P5) — belt-and-suspenders.
-
TDD (RED→GREEN):
- RED:
handleOpendialsws://127.0.0.1:3000atMuxOpen.requestPathreplayingMuxOpen.originHeaderas theOriginheader (so the UNCHANGED base app's Origin check passes againstALLOWED_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
OPENwhoseMuxOpen.subdomain !== cfg.subdomainis RST immediately and NEVER dialed — assertdialLoopbackis 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:
MuxOpenfailing the frozen §4.1 schema → stream RST, not a crash. - GREEN.
- RED:
-
Test cases: OPEN→dial with correct path+Origin; bidirectional splice; CLOSE frees socket; RST on invalid MuxOpen; OPEN for a foreign subdomain is RST, never dialed (cross-tenant boundary — assert no loopback dial); two-stream isolation (no cross-stream bleed); loopback connect-refused (base app down) → RST upstream + typed error (no swallow); backpressure hook calls into T11.
-
Security: INV11 (opaque splice), INV5 (per-connection allocation, no global buffers), INV1 defense-in-depth (subdomain-mismatch OPEN is RST before dial — the agent's own last-line check on a cross-wired relay frame; complements, never replaces, relay-side authz owned by P1/P5), Origin replay preserves CSWSH protection end-to-end (EXPLORE §3 "do not weaken the check").
T9 · Heartbeat [ ] — v0.9
- Owns:
agent/src/transport/heartbeat.ts,agent/test/heartbeat.test.ts - Depends: T7. Parallel-safe: with T8/T10/T11.
- Contracts (§4.1 PING/PONG, streamId 0, 15s — cited verbatim):
export interface Heartbeat { onPing(token: Uint8Array): void; start(): void; stop(): void; onDead(cb: () => void): void } export function createHeartbeat(tunnel: Tunnel, opts?: { intervalMs?: number; timer?: TimerLike }): Heartbeat export const HEARTBEAT_INTERVAL_MS = 15_000 // §4.1: "heartbeat every 15s, miss ⇒ tunnel dead" - TDD (fake timers): RED — inbound PING → agent replies PONG echoing the 8-byte token; no PONG within
interval →
onDeadfires (⇒ 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.tsisRevokedseam). Does NOT depend on T14 —reconnectLooponly consumes an injectedisRevoked: () => booleancallback (the seam type is frozen at W0 inseams.ts), so there is no task-level cycle with T14. T14 implementsRevocationStateand wires itsisRevokedinto 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"):
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()===trueshort-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_UPDATEcredit protocol (P1 owns the protocol; the agent is a credit-respecting sender/receiver so one heavyvim/topredraw can't starve another stream):export interface FlowController { consume(streamId: number, bytes: number): boolean // false ⇒ credit exhausted, pause this stream grant(streamId: number, credit: number): void // on inbound WINDOW_UPDATE initWindow(streamId: number, initialCredit: number): void } export function createFlowController(): FlowController - TDD: RED — sending past the initial window returns
false(pause); aWINDOW_UPDATE grantresumes; 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
/agentendpoint (mocked in unit tests). Parallel-safe: with T13/T14. - Contracts (INV14 — mTLS, no bearer token on the tunnel, EXPLORE §4a.1):
import { WsLike } from './seams' // W0 shared seam (T2) — NOT redeclared here export function dialRelay(cfg: AgentConfig, ks: Keystore): Promise<WsLike> // builds a wss:// client with { cert, key(in-process), ca: caChain }; rejectUnauthorized: true - TDD (RED→GREEN):
- RED: dial constructs a TLS client with the client cert + in-process private key + CA chain;
rejectUnauthorizedis 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).
- RED: dial constructs a TLS client with the client cert + in-process private key + CA chain;
- Test cases: cert+key+CA wired;
rejectUnauthorizedtrue; no token header; missing cert → NotEnrolled; expired cert → refuse; bad server chain → reject. - Security: INV14 (mTLS), INV4 (asymmetric, no shared secret on the wire), pinned CA (anti-MITM).
T13 · Short-lived cert rotation [ ] — v0.9 → hardened v0.10
- Owns:
agent/src/certs/rotation.ts,agent/test/rotation.test.ts - Depends: T3, T4, T12; cross-plan: P3 renewal endpoint (SPIFFE-style, §4.5 signs only pubkeys in the registry). Parallel-safe: with T14.
- Contracts (INV14 — auto-rotating, seamless):
export interface CertRotator { start(): void; stop(): void; onRotated(cb: () => void): void; onRevoked(cb: () => void): void } export function createCertRotator(cfg: AgentConfig, id: AgentIdentity, ks: Keystore, opts?: { renewBeforeMs?: number; timer?: TimerLike }): CertRotator // renews via P3 using a fresh CSR (T4) over the SAME Ed25519 key; installs new cert atomically (INV8-style swap) - TDD (fake timers): RED — timer fires at
expiry - renewBeforeMs, posts a fresh CSR, installs the new cert atomically (old cert never partially overwritten); a403 revokedfrom the renewal endpoint →onRevokedfires (⇒ 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.tsRevocationState/RevokeReasonseam); cross-plan: the frozen INDEX §4.1 3-valueGoAwayReasonunion +decodeGoAwayReasoninrelay-contracts/(§4.1 line 225 — RESOLVED, no longer an open question). T14 wires T10'sreconnectLoop(injects itsisRevokedinto 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/RevokeReasonseam is imported fromagent/src/transport/seams.ts(T2), not redeclared here; the GOAWAY reason mapping is imported fromrelay-contracts/, never invented locally:Finding-3 fix (RESOLVED) — GOAWAY reason semantics are a FROZEN shared contract, not a local numeric guess. INDEX §4.1 (line 225) now freezesimport { 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 unionGoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'(string-literal union) +decodeGoAwayReason(1→operatorDrain · 2→revoked · 3→shutdown, else throws) — the same codes P1drain.tsDRAIN_REASON(operatorDrain:1, revoked:2, shutdown:3) and P5 revocation (FIX 4) emit. T14 imports this union + decoder verbatim fromrelay-contracts/and must not hard-code reason integers or redeclare the union locally. This closes the earlier sender/receiver drift where P1 emittedoperatorDrain/shutdownwhile 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.classifyGoAwayis a pure exhaustive map over the frozen 3-value union: the graceful reasonsoperatorDrainandshutdownboth →'drain'(finish in-flight, reconnect elsewhere — EXPLORE §3 node drain/shutdown) andrevoked→'revoked'(immediate teardown, no reconnect). An unknown numeric code never yields a validGoAwayReason—decodeGoAwayReasonthrows — 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
GOAWAYdecoding (viadecodeGoAwayReason) to'revoked'→ immediate teardown of all streams + tunnel andisRevoked()becomes true so T10 does not reconnect; an'operatorDrain'or'shutdown'GOAWAY → graceful (finish in-flight, reconnect elsewhere); an unknown/unmapped reason code (decodeGoAwayReasonthrows) → 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 frozenGoAwayReason/decodeGoAwayReason(a test asserts no bare integer literal for a reason appears inrevocation.ts, and thatclassifyGoAwayis exhaustive over the 3-value union); unknown reason code fails closed to revoked (decodeGoAwayReasonthrows, INV12 safety); post-revoke reconnect suppressed (asserts the injectedisRevokedreturns 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 (FrameTransformseam), T19 (ReplaySealer, same wave — authored before T15 wires it); cross-plan HARD PRE-W4 GATE — P4relay-e2e/must FIRST freeze BOTH (a)sealFrame/openFrame+ thecreateHostHandshakehost-handshake wiring AND (b) the injected device-auth-proof verifier — P5-ownedverifyDeviceProof(proof, { clientEphPub, clientNonce })reaching T15 through P4'screateHostHandshake(FIX 6b, not a directrelay-e2eimport) — 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
deviceAuthProofverification (§4.4ClientHello). T15 only consumes that verifier; its issuance+verification lives in P5 and reaches T15 injected through P4'screateHostHandshake(FIX 6b) — the agent neverimports it fromrelay-e2e. Until P5'sverifyDeviceProof(bound to{ clientEphPub, clientNonce }) is frozen, wired into P4'screateHostHandshake, 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 forgedClientHellocomplete a full handshake and deriveDirectionalKeyswith 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
FrameTransforminto T8 (replacingidentityTransform). It imports P4's crypto and drives P4's host-handshake wiring (the device-auth-proof verifier reaches it injected, FIX 6b); it re-declares nothing from §4.4:// FIX 6b — verifier is P5-owned and injected via P4's createHostHandshake; NEVER `import { verifyDeviceAuthProof } from 'relay-e2e'`. import { sealFrame, openFrame, createHostHandshake } from 'relay-e2e' // P4 — frozen §4.4 primitives + host-handshake wiring import { createE2ESession } from 'relay-contracts' import type { ClientHello, HostHello, HandshakeResult, DirectionalKeys, E2ESession, AeadKey, } from 'relay-contracts' // §4.4 FROZEN shapes — cited verbatim, no local redefinition // Host-side device-proof verifier — INJECTED (P5 issues+verifies; P4 wires it), bound to { clientEphPub, clientNonce } (FIX 6b): export type VerifyDeviceProof = (proof: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }) => Promise<boolean> // Host side of §4.4: receive ClientHello (relay DATA) → verify proof FIRST → reply HostHello → derive DirectionalKeys. // MITM defense: makeHostHello drives createHostHandshake({ verifyDeviceProof, identity: id }), which calls the injected // verifier bound to { clientEphPub, clientNonce } FIRST and ABORTS (throws MitmAbortError, derives NO keys) on failure — // there is no code path that skips this call. export function makeHostHello( clientHello: ClientHello, id: AgentIdentity, verifyDeviceProof: VerifyDeviceProof, ): Promise<{ hello: HostHello; result: HandshakeResult }> // FIX 2 — returns HandshakeResult{ keys: DirectionalKeys{c2h,h2c}, aead, transcript }; NO single sessionKey/CryptoKey. // HostHello.sig = id.sign(transcript); HostHello.enrollFpr = id.enrollFpr (§4.4 — browser pins it, TOFU) export function createE2ETransform( id: AgentIdentity, verifyDeviceProof: VerifyDeviceProof, replay: ReplaySealer, // replay = T19 K_content sealer (FIX 3) ): FrameTransform // per stream: absorb ClientHello → (verify proof → else abort) → emit HostHello → // session = createE2ESession('host', result) // host seals with h2c, opens with c2h (§4.4) // thereafter: inbound = session.open(dataPayload); outbound(LIVE) = session.seal(plain) [ephemeral h2c]; // AND every replay-bound output is ALSO sealed via replay.seal(plain) [recoverable K_content, T19] — DISTINCT from live frames (FIX 3). // Synchronous AeadKey seal/open; monotonic seq per direction (§4.4 / INV13).Boundary: after
openFramedecrypts, 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:
makeHostHelloproduces aHostHellowhosesigverifies underenrollFprand whose ECDH yields aHandshakeResultwhosekeys: DirectionalKeys{ c2h, h2c }match what the P4 client-side test vector computes (§4.4 — direction-split, no singlesessionKey/CryptoKey). - RED (anti-MITM, gated on the frozen verifier + vector): using P4's published forged-proof
vector, a
ClientHellowhosedeviceAuthProoffails the injectedverifyDeviceProof→ ABORT (MitmAbortError, noDirectionalKeysderived, noHostHelloemitted); 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/createE2ETransformcan reach key derivation without calling the real (non-stub) injectedverifyDeviceProof— e.g. asserthostEndpoint.tsdoes NOT import any verifier fromrelay-e2e(the verifier arrives only via theverifyDeviceProofparam wired through P4'screateHostHandshake, FIX 6b), and a mutation test that replaces the injected verifier withasync () => truemakes 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):
openFramerejects 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.
- RED:
-
Acceptance criteria (explicit — mirrored in T18):
- 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'screateHostHandshake, FIX 6b); the forged-proof vector from P4 must be present and the abort test green. - The no-stub CI guard (mutation test above) is present and passing.
- All INV2/INV13/INV11 tests above green.
- T15 is BLOCKED, not degraded, until the proof-verification contract exists — no merge against a
stub/no-op/always-true
-
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 independentClientHellos derive two independentHandshakeResults 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
verifyDeviceProofis P5's real verifier wired via P4'screateHostHandshake, 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) + P4relay-e2e/impls. Wired INTO T15'screateE2ETransform(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 recoverableK_content = deriveContentKey({ hostContentSecret, sessionId, alg }), DISTINCT from the liveh2cframe. The browser re-derives the identicalK_content(via P5) and callsopenReplayCiphertext(P6 T9). This is the single agent-side consumer of the FIX 3 recoverable key (closes the "ship as unconsumed library code" gap noted in RECONCILE FIX 3a). - Contracts (imports the FROZEN §4.4 replay surface from
relay-contracts/; re-declares nothing):import { deriveContentKey, sealReplayFrame } from 'relay-e2e' // P4 impls of the §4.4 replay surface import type { ReplayKeyParams, AeadKey, E2EEnvelope, AeadAlg } from 'relay-contracts' // §4.4 FROZEN shapes export interface ReplaySealer { seal(plaintext: Uint8Array): E2EEnvelope // K_content seal, monotonic seq per session (INV13); NOT the live h2c frame } // K_content derived once per (host, session): deriveContentKey({ hostContentSecret, sessionId, alg }). export function createReplaySealer(hostContentSecret: Uint8Array, sessionId: string, alg: AeadAlg): ReplaySealerhostContentSecretcomes fromKeystore.loadContentSecret()(T3, unwrapped at T4) — never the ephemeral key, never logged, never sent to the relay (INV2/INV9). - TDD (RED→GREEN):
- RED:
deriveContentKeywith the same{ hostContentSecret, sessionId, alg }is deterministic (a reload re-derives the identicalK_content); a differentsessionId→ a different key (per-session). - RED:
sealemits a §4.4E2EEnvelopewith 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
sealReplayFrameenvelope round-trips through P6'sopenReplayCiphertextagainst a shared test vector (samehostContentSecret/sessionId). - GREEN by wiring P4 impls.
- RED:
- Test cases: deterministic
K_contentre-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 (
hostContentSecretat rest0600, never logged/egressed). The recoverableK_contentis 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): BuildSpecwhereBinaryTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64'(string-literal union); produces abun --compilespec (fallbackpkg) for a one-curl | shinstall.npx web-terminal-agentremains the MVP path (EXPLORE §6 rank 1 — "customers alreadynpm 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;
npxpath documented. - Security: EXPLORE §4d — the agent is a signed artifact (a compromised update = same blast radius, so the channel is signed/pinned; signing itself is P5/CI-owned, this task exposes the hook).
T17 · Service install + Origin touch-point [ ] — v0.9 (EXPLORE §6)
- Owns:
agent/src/service/install.ts,agent/src/service/launchd.ts,agent/src/service/systemd.ts,agent/src/service/originConfig.ts,agent/test/install.test.ts,agent/test/originConfig.test.ts - Depends: T2, T5. Parallel-safe: with T16.
- Contracts:
export type ServicePlatform = 'launchd' | 'systemd' export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null export function installService(cfg: AgentConfig, platform: ServicePlatform): Promise<void> // writes+loads unit export function uninstallService(platform: ServicePlatform): Promise<void> // the ONE base-app touch-point (INDEX §0, EXPLORE §3): add the subdomain to ALLOWED_ORIGINS as CONFIG export function ensureAllowedOrigin(baseAppEnvPath: string, subdomain: string, domain: string): voidensureAllowedOriginappendshttps://<subdomain>.term.<domain>to the base app'sALLOWED_ORIGINSenv (idempotent) — nosrc/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);ensureAllowedOriginis 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.subdomainis RST and never dialed — the agent's last-line cross-tenant boundary check. - INV12 GOAWAY-reason tripwire (T14): a
'revoked'GOAWAY (decoded via the frozendecodeGoAwayReason) tears down + suppresses reconnect;'operatorDrain'and'shutdown'reconnect elsewhere; an unknown reason code fails closed to revoked; assert no bare integer reason literal inrevocation.ts(cross-plan-drift guard). - INV2 anti-MITM no-stub gate (T15): with P4's forged-proof vector, a failing
deviceAuthProof→ ABORT (noDirectionalKeys). The verifier-mutation guard — replacing the injectedverifyDeviceProof(P5-owned, wired via P4'screateHostHandshake, 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 fromrelay-e2e). - FIX 3 recoverable-replay tripwire (T19): a replay-bound output sealed with
sealReplayFrame(K_content) round-trips through P6'sopenReplayCiphertext, is ciphertext to the relay (INV2), and is distinct from the liveh2cframe 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:3000echo 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-
verifyDeviceProofgate (Q#2) is unresolved (T14'sGoAwayReasoncontract 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:
- 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").
- 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
revokedGOAWAY is distinguished from the gracefuloperatorDrain/shutdownGOAWAY reasons only via the frozen 3-valuerelay-contractsGoAwayReason/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 sharedagentToken(T6) is an explicit, retired stepping-stone. - Relay sees only ciphertext (INV2) — from v0.10, every byte entering the tunnel is a §4.4
E2EEnvelopesealed at the agent: live frames under the ephemeralDirectionalKeys.h2c(T15), replay-bound frames under the recoverableK_contentviasealReplayFrame(T19, FIX 3) — both ciphertext to the relay. The handshake yieldsDirectionalKeys{c2h,h2c}(FIX 2, no singlesessionKey) and is enrollment-key-bound AND device-auth-proof-gated so a malicious relay cannot MITM (browser pinsenroll_fpr; the agent runs P4'screateHostHandshakewith the injected, P5-ownedverifyDeviceProofbound to{clientEphPub, clientNonce}(FIX 6b) and ABORTS on a forgedClientHello, deriving no keys). This guarantee is only real once that injectedverifyDeviceProofis P5's real verifier (wired via P4), never imported fromrelay-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 anyOPENwhoseMuxOpen.subdomain≠ the agent's enrolledsubdomain, 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." - 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).
- 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). - Secrets validated at startup, never logged (INV9) — Zod-validated config; fail-fast on missing key
material; a redacting logger; no
console.log. - 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).
- Origin check augmented, never weakened (EXPLORE §3) — the agent replays the real browser
Origin(§4.1MuxOpen.originHeader) to the base app and only adds the subdomain toALLOWED_ORIGINSat 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
# 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)
- 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. - [HARD PRE-W4 GATE — ownership RESOLVED by FIX 6b; the freeze/vector gate remains]
deviceAuthProofverifier (§4.4ClientHello). Ownership is now settled: P5 issues+verifies the proof (verifyDeviceProof(proof, { clientEphPub, clientNonce })); P4 wires it intocreateHostHandshake; P2/T15 consumes it INJECTED through P4 — neverimport { 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'screateHostHandshake, 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 forgedClientHellocomplete a handshake and deriveDirectionalKeys— 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. - [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 P1drain.tsDRAIN_REASON(operatorDrain:1, revoked:2, shutdown:3) and P5 revocation (FIX 4) emit. T14 imports the union + decoder verbatim fromrelay-contracts/(no local redefinition), mapsoperatorDrain/shutdown→ drain (reconnect elsewhere) andrevoked→ revoke (stop), and fails closed torevokedon any unknown numeric code (decodeGoAwayReasonthrows). 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. - v0.8
agentTokentransport auth vs frp's own token — T6 wrapsfrpc; whether the shared token is frp'sauth.tokenor a P3-issued value affects the P3 flat-table schema (§4.2 note). Confirm with P3 before wiring T6'sfrpc.toml. - 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.
- 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.