Files
web-terminal/docs/PLAN_RELAY_RUN_PHASE0.md
Yaojia Wang ba85871227 feat(relay-run): Phase 0 single-host relay — real data-plane wiring + integration test
New relay-run/ package boots the REAL term-relay relay-node between a browser WSS
listener and an agent mTLS listener, delegating every authz verdict to the real
relay-auth onUpgrade (Origin/CSWSH + capability verify + DPoP PoP + single-use jti),
with the real P4 E2E crypto. No audited package modified.

- P1 (done): in-process integration test round-trips a sealed payload both ways
  through the real createRelayNode splice + real createMuxSession; INV2 asserted
  (relay sees only ciphertext); negative controls (foreign Origin / missing DPoP -> 401).
- P2 (boots): main.ts brings up self-signed TLS + 3 listeners + in-memory control-plane.
- P3 (not landed): no agent dial / node-pty / relay-web serve yet.
- 4 tests green, tsc clean. node_modules via symlinks (gitignored).

Two real seam drifts surfaced (documented in PLAN_RELAY_RUN_PHASE0, not hacked):
(1) term-relay authz-port DpopContext shape vs relay-auth — reconciled (server-derived htu/htm);
(2) control-plane CapabilityVerifier.verify is sync but relay-auth verify is async — blocks
    HTTP account/pairing seeding until a 1-line async widen.
2026-07-04 14:13:04 +02:00

7.7 KiB
Raw Permalink Blame History

Phase 0 — single-host runnable relay (build spec)

Goal: a single npm start that boots an end-to-end relay you can click through locally — real P4(crypto)+P5(auth) wiring, in-memory stores, self-signed TLS, agent dialing localhost, relay-web served. No Postgres/Redis/KMS. This is the executable spec for the Phase 0 milestone in DEPLOY_RELAY.md §4. Reuses the wiring proven in e2e/harness/.

Run this build in a FRESH session (recommend /compact first). Read the seam files named below before writing — the exact interface shapes must be confirmed against the code, not assumed.

Deliverable

A new package relay-run/ (top-level, symlink node_modules like e2e/) with one entry relay-run/src/main.ts that npm start runs, plus small wiring modules. Result: browser at https://localhost:8443/ → authenticates → live terminal to a shell on the same machine, spliced through the real relay-node, E2E-encrypted (relay sees only ciphertext).

Seam files to read FIRST (confirm exact shapes)

  • term-relay/data-plane/ws-like.ts — the WebSocketLike interface the relay-node consumes.
  • term-relay/data-plane/agent-listener.tsAgentListener/AgentTunnel shapes (how an agent mux attaches).
  • term-relay/data-plane/relay-node.ts + upgrade.ts + authz-port.tscreateRelayNode, authorizeUpgrade, the Authorizer port (onUpgrade/onReattach).
  • term-relay/mux/mux-session.tscreateMuxSession, MuxStreamHandle (frame the agent side).
  • control-plane/src/main.tsbuildControlPlane/overrides (stores, verifier, bus, kmsResolver), createMemoryStores, inProcessCaSigner.
  • agent/src/cli.ts + agent/src/e2e/hostEndpoint.ts + transport/dial.ts — how the agent dials + runs the E2E host endpoint + loopback splice.
  • e2e/harness/world.ts — the REFERENCE wiring: it already composes real P5 onUpgrade + P4 handshake + fakes. Lift the EnforceDeps/registry/bucket/audit construction from here (and e2e/harness/fakes.ts).

Build steps (order)

  1. Scaffold relay-run/: package.json (type module, "start":"tsx src/main.ts", dep on tsx, ws), tsconfig.json, and node_modules symlinks to all relay packages + ws/tsx (mirror e2e/s manual-symlink approach — avoids npm install). Add relay-run/node_modules to .gitignore coverage (root already ignores node_modules/).
  2. Self-signed TLS: generate a localhost cert at boot (node selfsigned or a checked-in dev cert) for the browser WSS listener; a throwaway CA for agent mTLS.
  3. Control-plane (in-memory): boot buildControlPlane with overrides = { stores: createMemoryStores(), verifier: <real P5 verifyCapabilityToken bound to the dev P5 pubkey>, bus: in-memory, kmsResolver: inProcessCaSigner }. Listen on :8080. Seed one account + issue a pairing code programmatically at boot.
  4. P5 EnforceDeps: build the real EnforceDeps (hosts/sessions/revocation/buckets/audit + stepUpPolicyFor: () => NO_STEPUP_POLICY) backed by the in-memory control-plane registries — copy the construction from e2e/harness. Configure the P5 verify key (configureVerifyKey).
  5. WebSocketLike adapter: wrap ws WebSocket → the WebSocketLike interface (send/close/onmessage/onclose per ws-like.ts). Browser WSS server on :8443; on upgrade, build the UpgradeRequest (extract token from subprotocol/cookie, Origin, DPoP header) and call authorizeUpgrade (→ real onUpgrade). Close F2 here: pass a resolved principal when the host policy requires step-up (Phase 0 default policy = not-required, so null is fine).
  6. AgentListener adapter: mTLS tls.Server on :8444; verify the agent client cert against the dev CA (verifyAgentCert), attach each as an AgentTunnel + createMuxSession. Wire createRelayNode({ config, agentListener, authorizer }) and the opaque splice.
  7. Agent: build the CLI (dist/cli.js) or run agent in-process; enroll against control-plane POST /enroll with the seeded pairing code (→ SPIFFE cert + hostContentSecret + subdomain); dial wss://localhost:8444 (mTLS) and run the E2E host endpoint splicing to a local shell (LOCAL_TARGET_URL / node-pty).
  8. relay-web: npm --prefix relay-web run build; serve the bundle from the browser listener (same origin as WSS so Origin/CSWSH passes). Wire the login → P5 capability-token mint (Phase 0: a dev endpoint that mints a token after a stub human-auth, or reuse issueCapabilityToken directly).
  9. main.ts: bring up 3→5→6→7→8 in order; print the URL + a ready-to-use token/subdomain.

Verify

  • npm start in relay-run/ boots without error; console prints the local URL + subdomain.
  • Browser: open https://localhost:8443/?..., authenticate, see a live shell; type whoami → output.
  • Kill/restart the agent → session survives per design (reconnect); revoke() → stream tears down.
  • Add a smoke test (Vitest) that boots the world in-process and round-trips one command E2E through the real relay-node (extend the e2e/harness pattern with the real WebSocketLike adapter).

Explicitly NOT in Phase 0

Postgres/Redis (in-memory only), real KMS (dev in-process signer), wildcard TLS/multi-tenant subdomain routing, horizontal scaling, F6 replay-epoch transport (preview stays inert), metering. Those are Phase 1/2 in DEPLOY_RELAY.md.

Build outcome (2026-07-04) + seam drifts found

Built as relay-run/. P1 achieved + verified (4 tests green, tsc clean): an in-process integration test round-trips a sealed payload both directions through the REAL createRelayNode splice + REAL createMuxSession, authorized by the REAL relay-auth onUpgrade (Origin/CSWSH + capability verify + DPoP PoP + single-use jti) with REAL P4 E2E; INV2 asserted (relay sees only ciphertext); negative controls (foreign Origin → 401, missing DPoP → 401). P2 achieved (npm start boots self-signed TLS + browser WSS :8443 + agent mTLS :8444 + control-plane :8080, prints a minted capability token). P3 not landed (no agent dial / node-pty / relay-web serve). No audited package modified.

Two real integration blockers surfaced by actually wiring it (reported, not hacked):

  1. DpopContext shape drift — term-relay's authz-port DpopContext is {proof, publicKeyThumbprint} but relay-auth needs {proofJws, htu, htm}. Reconciled in relay-run/src/wiring/authorizer.ts by deriving htu/htm from the relay's own authority (expectedAud) — spec-faithful (RFC 9449), the DPoP binding stays fully enforced. Consider aligning the port type upstream.
  2. control-plane CapabilityVerifier.verify is SYNC, relay-auth verifyCapabilityToken is ASYNC (WebCrypto). The real P5 verifier therefore cannot be injected into the control-plane admin routes → programmatic account+pairing seeding is blocked in Phase 0 (control-plane boots on its default fail-closed verifier). Fix: widen CapabilityVerifier.verify to return Promise<CapabilityToken> (1-line additive in control-plane/src/api/authz.ts), then inject the real async verifier.

Next steps to a full browser click-through: (a) the async-verify widen above; (b) a browser-usable DPoP carrier on the WS upgrade (subprotocol/cookie/preflight — browsers can't set a dpop header); (c) run the agent (enroll → mTLS dial → E2E host endpoint → node-pty); (d) swap the Phase-0 mtls.verifyPeer stub for the registry-gated verifyAgentCert and serve the built relay-web bundle.

Security notes (keep even in dev)

  • Real Origin/CSWSH check on the WSS upgrade (dont stub it off for localhost — set allowedOrigins to the dev origin). Real capability-token verify + DPoP (no bypass). Self-signed certs are dev-only; never reuse the dev CA anywhere real. hostContentSecret 0600, never logged.