Files
web-terminal/relay-run/tests/integration.test.ts
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

198 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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('')
expect(asText).not.toContain('whoami')
expect(asText).not.toContain('uid=501')
})
it('denies a foreign Origin (real CSWSH check) → 401, no stream opened', 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,
})
const agent = attachAgent(dp, world)
const cap = await world.issueCap()
const { a: relayBrowserWs } = makeSocketPair()
let closedWith: number | undefined
const ws: WebSocketLike = {
send: () => {},
close: (c) => {
closedWith = c
},
onMessage: () => {},
onClose: () => {},
}
void relayBrowserWs
await dp.node.handleBrowserUpgrade(
upgradeRequest(world, cap.raw, cap.dpop.proofJws, 'https://evil.example.com'),
ws,
)
expect(closedWith).toBe(401)
expect(agent.stream()).toBeNull()
expect(world.audit.events.some((e) => e.reason === 'bad_origin')).toBe(true)
})
it('denies a missing DPoP proof (real PoP check) → 401, no stream opened', 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,
})
const agent = attachAgent(dp, world)
const cap = await world.issueCap()
let closedWith: number | undefined
const ws: WebSocketLike = {
send: () => {},
close: (c) => {
closedWith = c
},
onMessage: () => {},
onClose: () => {},
}
await dp.node.handleBrowserUpgrade(upgradeRequest(world, cap.raw, '' /* no DPoP */), ws)
expect(closedWith).toBe(401)
expect(agent.stream()).toBeNull()
})
})