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.
198 lines
7.5 KiB
TypeScript
198 lines
7.5 KiB
TypeScript
/**
|
||
* Phase 0 P1 integration — proves the REAL data-plane wiring works IN-PROCESS:
|
||
* browser upgrade → real `authorizeUpgrade` → real relay-auth `onUpgrade` (Origin/CSWSH + token
|
||
* verify + DPoP PoP + single-use jti) → real `createRelayNode` opaque splice → a REAL agent-side
|
||
* `createMuxSession`. One application payload is sealed by the browser (P4 E2E), spliced through
|
||
* the relay, and opened by the agent — and vice versa. INV2 is asserted directly: every byte the
|
||
* relay handled on the browser socket is ciphertext (never the plaintext).
|
||
*/
|
||
import { describe, it, expect } from 'vitest'
|
||
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
|
||
import { createMuxSession, type MuxStreamHandle } from 'term-relay/mux/mux-session.js'
|
||
import type { WebSocketLike } from 'term-relay/data-plane/ws-like.js'
|
||
import type { UpgradeRequest } from 'term-relay/data-plane/upgrade.js'
|
||
import type { ScheduleHandle } from 'term-relay/mux/heartbeat.js'
|
||
import { buildPhase0World, type Phase0World } from '../src/wiring/relay-world.js'
|
||
import { buildDataPlane } from '../src/wiring/data-plane.js'
|
||
import { makeSocketPair } from '../src/wiring/socket-pipe.js'
|
||
|
||
const enc = new TextEncoder()
|
||
const dec = new TextDecoder()
|
||
|
||
/** A scheduler that never fires — keeps heartbeat timers out of the deterministic test. */
|
||
const manualSchedule = (_fn: () => void, _ms: number): ScheduleHandle => ({ cancel: () => {} })
|
||
|
||
/** Records every buffer the relay handled on the browser socket (both directions) for INV2. */
|
||
function recordingWs(inner: WebSocketLike, seen: Uint8Array[]): WebSocketLike {
|
||
return {
|
||
send: (d) => {
|
||
seen.push(d)
|
||
inner.send(d)
|
||
},
|
||
close: (c) => inner.close(c),
|
||
onMessage: (h) =>
|
||
inner.onMessage((d) => {
|
||
seen.push(d)
|
||
h(d)
|
||
}),
|
||
onClose: (h) => inner.onClose(h),
|
||
}
|
||
}
|
||
|
||
interface AgentSide {
|
||
stream(): MuxStreamHandle | null
|
||
inbox: Uint8Array[]
|
||
}
|
||
|
||
/** Attach a REAL agent-side mux to the relay listener over an in-memory socket pair. */
|
||
function attachAgent(dp: ReturnType<typeof buildDataPlane>, world: Phase0World): AgentSide {
|
||
const { a: relaySide, b: agentSide } = makeSocketPair()
|
||
const inbox: Uint8Array[] = []
|
||
let agentStream: MuxStreamHandle | null = null
|
||
|
||
const agentMux = createMuxSession({
|
||
role: 'agent',
|
||
sendWire: (frame) => agentSide.send(frame),
|
||
onOpen: (_open, stream) => {
|
||
agentStream = stream
|
||
stream.onData((cipher) => inbox.push(cipher)) // opaque cipher from the relay splice
|
||
stream.onClose(() => {})
|
||
},
|
||
onData: () => {},
|
||
onClose: () => {},
|
||
onDead: () => {},
|
||
maxFrameBytes: 1024 * 1024,
|
||
initialWindowBytes: 256 * 1024,
|
||
schedule: manualSchedule,
|
||
})
|
||
agentSide.onMessage((b) => agentMux.onWire(b))
|
||
agentSide.onClose(() => agentMux.close())
|
||
|
||
// The relay accepts the agent tunnel (verifyPeer → registry-bound hostId).
|
||
dp.listener.attach(relaySide, new Uint8Array([0xde, 0xad]))
|
||
return { stream: () => agentStream, inbox }
|
||
}
|
||
|
||
function upgradeRequest(world: Phase0World, capRaw: string, dpopProof: string, origin?: string): UpgradeRequest {
|
||
return {
|
||
host: `${world.host.subdomain}.${world.baseDomain}`,
|
||
origin: origin ?? world.host.origin,
|
||
url: '/term',
|
||
subprotocols: [APP_SUBPROTOCOL, encodeTokenSubprotocol(capRaw)],
|
||
cookies: {},
|
||
remoteAddr: '127.0.0.1',
|
||
dpop: { proof: dpopProof, publicKeyThumbprint: null },
|
||
activeSessionCount: 0,
|
||
}
|
||
}
|
||
|
||
describe('Phase 0 · real relay-node splice (P1)', () => {
|
||
it('round-trips one E2E payload through the real splice; relay sees only ciphertext (INV2)', async () => {
|
||
const world = await buildPhase0World()
|
||
const dp = buildDataPlane({
|
||
config: (await import('../src/wiring/data-plane.js')).makeDataPlaneConfig({ baseDomain: world.baseDomain }),
|
||
authorizer: world.authorizer,
|
||
resolver: world.resolver,
|
||
mtls: world.mtls,
|
||
now: () => world.now,
|
||
schedule: manualSchedule,
|
||
})
|
||
|
||
// P4: establish the real browser↔agent E2E session out-of-band (authenticated ECDH).
|
||
const e2e = await world.establishSession()
|
||
const agent = attachAgent(dp, world)
|
||
|
||
// Browser upgrade — the REAL authz path (Origin/CSWSH + capability verify + DPoP PoP).
|
||
const cap = await world.issueCap()
|
||
const { a: relayBrowserWs, b: browserWs } = makeSocketPair()
|
||
const relaySeen: Uint8Array[] = []
|
||
const browserReceived: Uint8Array[] = []
|
||
browserWs.onMessage((d) => browserReceived.push(d))
|
||
|
||
await dp.node.handleBrowserUpgrade(
|
||
upgradeRequest(world, cap.raw, cap.dpop.proofJws),
|
||
recordingWs(relayBrowserWs, relaySeen),
|
||
)
|
||
|
||
// Authorization passed → the relay opened a stream on the agent tunnel → splice is live.
|
||
expect(agent.stream()).not.toBeNull()
|
||
|
||
// Browser → agent: seal 'whoami\n' (c2h), splice opaque, agent opens it.
|
||
const c2h = e2e.client.seal(enc.encode('whoami\n'))
|
||
browserWs.send(c2h)
|
||
expect(agent.inbox.length).toBe(1)
|
||
expect(dec.decode(e2e.host.open(agent.inbox[0]!))).toBe('whoami\n')
|
||
|
||
// Agent → browser: seal 'uid=501\n' (h2c), splice opaque, browser opens it.
|
||
const h2c = e2e.host.seal(enc.encode('uid=501\n'))
|
||
agent.stream()!.writeData(h2c)
|
||
expect(browserReceived.length).toBe(1)
|
||
expect(dec.decode(e2e.client.open(browserReceived[0]!))).toBe('uid=501\n')
|
||
|
||
// INV2: every byte the relay handled on the browser socket is ciphertext, never plaintext.
|
||
expect(relaySeen.length).toBeGreaterThan(0)
|
||
const asText = relaySeen.map((b) => dec.decode(b)).join(' |