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.
80 lines
7.7 KiB
Markdown
80 lines
7.7 KiB
Markdown
# 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 (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.
|