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

80 lines
7.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 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](./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.ts``AgentListener`/`AgentTunnel` shapes (how an agent mux attaches).
- `term-relay/data-plane/relay-node.ts` + `upgrade.ts` + `authz-port.ts``createRelayNode`, `authorizeUpgrade`, the `Authorizer` port (`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 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.