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.
7.1 KiB
Relay Plans — Cross-Plan Reconciliation TODO
The 6 relay plans + PLAN_RELAY_INDEX.md were generated and hardened in parallel
(32-agent workflow: draft → security+architecture review → fix → re-review → global
consistency). Each plan passed its own review (4–8 blocking findings fixed each). The
global consistency review then found 11 CRITICAL/HIGH cross-plan integration issues —
all one root cause: plans drifted their SHARED contracts locally during hardening
instead of routing changes through the frozen INDEX §4 (violating INDEX §2 rule 1).
These must be reconciled BEFORE any plan's implementation is dispatched, or the plans will not compose (they'd type-mismatch / fail at integration). The fix is a reconciliation pass: freeze the drifted/missing contracts at the INDEX coordination point, then sync the consumer plans. Below, the 11 findings collapse into 6 concrete fixes.
FIX 1 — relay-contracts/ (W0) has no owning task (GLOBAL CRITICAL)
Every plan imports relay-contracts/ read-only and INDEX says it's "frozen before any
plan's W1", but no plan contains a task to AUTHOR it (Zod schemas + TS types + the
mux binary/CBOR codec). It's also where several still-unfrozen shapes must land.
- Do: add an INDEX-owned W0 task that builds
relay-contracts/: §4 Zod schemas + inferred types,encodeMuxFrame/decodeMuxFrame(+ CBOR),GoAwayReason/decodeGoAwayReason, the amended §4.4 shapes (Fix 2), account/session/pairing TS types, INV8 version-table DDL. Freeze before dispatching any W1.
FIX 2 — §4.4 E2E contract drift (single sessionKey: CryptoKey → evolved shape) (E2E HIGH, GLOBAL HIGH, FRONTEND HIGH)
P4 (E2E) evolved to: AeadKey (not CryptoKey), synchronous sealFrame/openFrame,
aad = directionLabel‖seq, direction-split DirectionalKeys (c2h/h2c) wrapped in a
stateful E2ESession (.seal()/.open()/.rederive()), handshake via
buildClientHandshake(ctx, hostId, aeadOffer). But INDEX §4.4, P2 T15 makeHostHello
(returns sessionKey: CryptoKey), and P6 T8 (raw SubtleCrypto AES-GCM, single key)
still use the ORIGINAL shape → they can't type-check against what P4 exports.
- Do: land P4 §9 Q4's amendments into INDEX §4.4 / relay-contracts first
(
AeadKey, sync sigs,DirectionalKeysc2h/h2c,aad=directionLabel‖seq,ReplayKeyParams). Then update P2 T15 →HandshakeResultcarryingDirectionalKeys; P6 T8/T9 → consumeE2ESession/buildClientHandshake/AeadKey. Closes P6 §8 Q4 by construction.
FIX 3 — recoverable content-key (K_content/hostContentSecret) has no wiring or delivery (E2E HIGH, GLOBAL HIGH)
The LOCKED "ciphertext ring-buffer survives reconnect" decision (EXPLORE §0 dec.2 / §4c)
needs a recoverable key: live frames use the ephemeral h2c key, but replay/preview
frames must use K_content derived from a host-scoped hostContentSecret. Problems:
(a) P2 never calls sealReplayFrame; P6 T9 never calls openReplayCiphertext → T10/T11
ship as unconsumed library code; (b) hostContentSecret has no delivery — P2 T4 and
P3 T8 EnrollResult return only {hostId, subdomain, cert, caChain}.
- Do: add
hostContentSecret(wrapped to the agent's identity) to the §4.5 enrollment contract +EnrollResult; assign minting/wrapping to P3 or P5 at BIND, receipt/keystore to P2. Add consumer tasks: P2 seals every replay-bound host→client output withsealReplayFrame; P6 T9 decrypts viaopenReplayCiphertext/deriveContentKey. Extend T10/T11's merge gate to include this wiring.
FIX 4 — INV12 revocation teardown has 3 incompatible descriptions + no P1 subscriber (AUTH HIGH, GLOBAL CRITICAL)
P5 T10 publishes KillSignal on Redis pub/sub relay:revocations and asserts "P1
subscribes and injects §4.1 RST/GOAWAY". P3 T13 pushes over an unowned "control→node
channel (OQ4)". P1 T10/T11 imply P5 calls it directly. relay:revocations + KillSignal
are NOT in INDEX §4.2 (only route:{host_id} / revoked:{jti} are), and P1 has no
subscriber task; KillSignal is defined P5-locally (would force P1→P5 import, against DAG).
- Do: freeze ONE control→data-plane teardown contract in INDEX §4 —
relay:revocationspub/sub +KillSignalshape, promoted intorelay-contracts. Assign P1 an explicit task that subscribes and turnsKillSignal→ §4.1 CLOSE+RST/GOAWAY viacloseStream/closeTunnel. Reconcile P3 OQ4 drain channel to the same named channel.
FIX 5 — capability-token WS subprotocol wire format diverges (FRONTEND HIGH)
P6 T4: new WebSocket(url, ["relay.capability.v1", token]) (raw token). P1
extractCapabilityToken: ['term.relay.v1', 'term.token.' + base64url(token)] (prefixed +
encoded, relay strips before verify; must echo only the app subprotocol). Neither is in
INDEX §4.3 → invented independently and diverged → real upgrade 401s while isolated tests pass.
- Do: freeze the exact app-subprotocol string +
term.token.prefix + base64url encoding + "accepted subprotocol must be the app one, never the token" rule in INDEX §4.3. Update P6 T4/T8 verbatim + assertws.protocol === 'term.relay.v1'after open (tear down otherwise).
FIX 6 — two more single-owner violations (GLOBAL HIGH ×2)
- 6a. WS-upgrade auth implemented twice. P1 T8
authorizeUpgradere-implements the decision (Origin+token+cross-tenant+rights) while P5 T12onUpgradeis the real gate (adds DPoP PoP, single-use burn, pre-auth throttle, step-up, per-tenant rate, audit). → Make P1 T8 a thin adapter that DELEGATES to P5onUpgrade/onReattach; add DPoP +activeSessionCountto P1'sUpgradeRequest. Authorization DECISION has exactly one owner (P5). - 6b.
deviceAuthProofverifier has 3 signatures + ambiguous home. P5 T2 ownsverifyDeviceAuthProof(proof, expectedAccountId, transcriptHash, now); P4 T8 consumes an injectedverifyDeviceProof(proof, {clientEphPub, clientNonce}); P2 T15 imports it fromrelay-e2e(wrong package). → P5 owns issuance+verification (binding to{clientEphPub, clientNonce}per P4 Q3); P4 consumes as injected dep; P2 gets it via P4's host-handshake wiring (fix the import). Unify the binding param across P2/P4/P5.
Reconciliation pass shape (recommended)
- Amend
PLAN_RELAY_INDEX.md(single agent) — land Fixes 1,2,3,4,5,6 contract decisions into §4 (+ the W0 relay-contracts task). This is the coordination point; do it first. - Sync consumer plans (parallel, file-disjoint): P1 TRANSPORT (Fix 4 subscriber, 6a delegate, 5 confirm), P2 AGENT (Fix 2 DirectionalKeys, 3 seal+receive secret, 6b import), P3 CONTROLPLANE (Fix 3 EnrollResult, 4 OQ4 reconcile), P6 FRONTEND (Fix 2 T8/T9, 3 T9, 5 T4/T8). P4/P5 mostly already correct — minor confirmations.
- Re-run the global consistency review to confirm zero residual contract drift.
Effort: ~7 agents (1 index + ~5 consumer syncs + 1 re-review) — far smaller than the generation workflow. Do it as its own step, ideally in a fresh conversation (read this file
- the 7 plan docs).