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.
7.7 KiB
Phase 0 — single-host runnable relay (build spec)
Goal: a single
npm startthat boots an end-to-end relay you can click through locally — real P4(crypto)+P5(auth) wiring, in-memory stores, self-signed TLS, agent dialinglocalhost,relay-webserved. No Postgres/Redis/KMS. This is the executable spec for the Phase 0 milestone in DEPLOY_RELAY.md §4. Reuses the wiring proven ine2e/harness/.Run this build in a FRESH session (recommend
/compactfirst). 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— theWebSocketLikeinterface the relay-node consumes.term-relay/data-plane/agent-listener.ts—AgentListener/AgentTunnelshapes (how an agent mux attaches).term-relay/data-plane/relay-node.ts+upgrade.ts+authz-port.ts—createRelayNode,authorizeUpgrade, theAuthorizerport (onUpgrade/onReattach).term-relay/mux/mux-session.ts—createMuxSession,MuxStreamHandle(frame the agent side).control-plane/src/main.ts—buildControlPlane/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 P5onUpgrade+ P4 handshake + fakes. Lift theEnforceDeps/registry/bucket/audit construction from here (ande2e/harness/fakes.ts).
Build steps (order)
- Scaffold
relay-run/:package.json(type module,"start":"tsx src/main.ts", dep ontsx,ws),tsconfig.json, andnode_modulessymlinks to all relay packages +ws/tsx(mirrore2e/’s manual-symlink approach — avoidsnpm install). Addrelay-run/node_modulesto.gitignorecoverage (root already ignoresnode_modules/). - Self-signed TLS: generate a localhost cert at boot (node
selfsignedor a checked-in dev cert) for the browser WSS listener; a throwaway CA for agent mTLS. - Control-plane (in-memory): boot
buildControlPlanewithoverrides = { 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. - 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 frome2e/harness. Configure the P5 verify key (configureVerifyKey). WebSocketLikeadapter: wrapwsWebSocket→ theWebSocketLikeinterface (send/close/onmessage/onclose per ws-like.ts). Browser WSS server on:8443; on upgrade, build theUpgradeRequest(extract token from subprotocol/cookie, Origin, DPoP header) and callauthorizeUpgrade(→ realonUpgrade). Close F2 here: pass a resolved principal when the host policy requires step-up (Phase 0 default policy = not-required, sonullis fine).AgentListeneradapter: mTLStls.Serveron:8444; verify the agent client cert against the dev CA (verifyAgentCert), attach each as anAgentTunnel+createMuxSession. WirecreateRelayNode({ config, agentListener, authorizer })and the opaque splice.- Agent: build the CLI (
dist/cli.js) or runagentin-process; enroll against control-planePOST /enrollwith the seeded pairing code (→ SPIFFE cert +hostContentSecret+ subdomain); dialwss://localhost:8444(mTLS) and run the E2E host endpoint splicing to a local shell (LOCAL_TARGET_URL/ node-pty). - 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 reuseissueCapabilityTokendirectly). main.ts: bring up 3→5→6→7→8 in order; print the URL + a ready-to-use token/subdomain.
Verify
npm startinrelay-run/boots without error; console prints the local URL + subdomain.- Browser: open
https://localhost:8443/?..., authenticate, see a live shell; typewhoami→ 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/harnesspattern with the realWebSocketLikeadapter).
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):
- DpopContext shape drift — term-relay's
authz-portDpopContextis{proof, publicKeyThumbprint}but relay-auth needs{proofJws, htu, htm}. Reconciled inrelay-run/src/wiring/authorizer.tsby derivinghtu/htmfrom the relay's own authority (expectedAud) — spec-faithful (RFC 9449), the DPoP binding stays fully enforced. Consider aligning the port type upstream. - control-plane
CapabilityVerifier.verifyis SYNC, relay-authverifyCapabilityTokenis 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: widenCapabilityVerifier.verifyto returnPromise<CapabilityToken>(1-line additive incontrol-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 (don’t 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.