import { describe, expect, it, vi } from 'vitest' import { deriveContentKey, openReplayCiphertext, sealReplayFrame } from 'relay-e2e' import { randomBytes } from 'relay-e2e' import { mountPreviewClient, type PreviewDeps, type ReadonlyTerminalLike, type ReplaySource, } from '../src/preview-client' const HOST_SECRET = randomBytes(32) const SESSION_ID = 'sess-1' const ALG = 'aes-256-gcm' as const const EPOCH = 'gen-1' // FIX 3b / F6: the sealer generation these fixtures seal + declare under. interface SealOpts { readonly secret?: Uint8Array /** Epoch the FRAMES are actually sealed under (the key that encrypts). */ readonly sealEpoch?: string /** Epoch the ReplaySource DECLARES (the key the browser will re-derive). Defaults to sealEpoch. */ readonly declaredEpoch?: string } /** * Seal a screen (as a P2 agent stub would) under K_content with sealReplayFrame. Recoverability * holds because the frames are sealed with the SAME (secret, sessionId, alg, epoch) the returned * ReplaySource declares. For the F6 regression, `sealEpoch` and `declaredEpoch` are set apart so the * browser derives a different key than the frames were sealed under. */ function sealedReplay(lines: readonly string[], opts: SealOpts = {}): ReplaySource { const secret = opts.secret ?? HOST_SECRET const sealEpoch = opts.sealEpoch ?? EPOCH const declaredEpoch = opts.declaredEpoch ?? sealEpoch const k = deriveContentKey({ hostContentSecret: secret, sessionId: SESSION_ID, alg: ALG, epoch: sealEpoch, }) const frames = lines.map((line, i) => // sealReplayFrame → E2EEnvelope, encoded to a DATA payload the browser opens. // openReplayCiphertext decodes+opens; we mirror the encode via the session envelope codec. encodeReplayFrame(sealReplayFrame(k, BigInt(i), new TextEncoder().encode(line))), ) return { sessionId: SESSION_ID, alg: ALG, epoch: declaredEpoch, frames } } // The replay codec: relay-e2e's openReplayCiphertext expects the encoded envelope bytes. import { encodeEnvelope } from 'relay-e2e' function encodeReplayFrame(env: ReturnType): Uint8Array { return encodeEnvelope(env) } const realDeps: PreviewDeps = { deriveContentKey, openReplayCiphertext } function mockTerminal(): ReadonlyTerminalLike & { written: string[] } { const written: string[] = [] return { written, open: () => {}, write: (d) => written.push(d), dispose: () => {} } } describe('mountPreviewClient (T9)', () => { it('recoverable-key decrypt: sealReplayFrame frames render into the read-only xterm (FIX 3)', async () => { const card = document.createElement('div') const term = mockTerminal() const client = mountPreviewClient( card, sealedReplay(['line-A', 'line-B']), HOST_SECRET, { cols: 80, rows: 24 }, { ...realDeps, createTerminal: () => term }, ) await client.render() expect(term.written).toEqual(['line-A', 'line-B']) }) it('wrong/host-mismatched secret → openReplayCiphertext throws → "unavailable", never garbled', async () => { const card = document.createElement('div') const term = mockTerminal() const replay = sealedReplay(['secret-screen']) // sealed under HOST_SECRET const client = mountPreviewClient( card, replay, randomBytes(32), // a DIFFERENT host secret → derives a different K_content { cols: 80, rows: 24 }, { ...realDeps, createTerminal: () => term }, ) await client.render() expect(card.querySelector('.preview-unavailable')?.textContent).toBe('unavailable') expect(term.written).toEqual([]) // never wrote a torn screen }) it('F6: a ReplaySource epoch that does NOT match the sealed generation → "unavailable", never a torn screen', async () => { const card = document.createElement('div') const term = mockTerminal() // Frames sealed under generation "gen-1", but the ReplaySource declares a DIFFERENT epoch // ("gen-2") — as if the ring buffer were mislabeled with the wrong generation. The browser // re-derives a different K_content → AEAD tag fails → unavailable (no garbled render). const replay = sealedReplay(['stale-screen'], { sealEpoch: 'gen-1', declaredEpoch: 'gen-2' }) const client = mountPreviewClient(card, replay, HOST_SECRET, { cols: 80, rows: 24 }, { ...realDeps, createTerminal: () => term, }) await client.render() expect(card.querySelector('.preview-unavailable')?.textContent).toBe('unavailable') expect(term.written).toEqual([]) // never wrote a torn screen from the wrong generation }) it('F6: a matching per-generation epoch preserves recoverability (frames render)', async () => { const card = document.createElement('div') const term = mockTerminal() // sealEpoch === declaredEpoch (both "gen-7") ⇒ same key within one generation ⇒ replay recovers. const replay = sealedReplay(['line-A', 'line-B'], { sealEpoch: 'gen-7', declaredEpoch: 'gen-7' }) const client = mountPreviewClient(card, replay, HOST_SECRET, { cols: 80, rows: 24 }, { ...realDeps, createTerminal: () => term, }) await client.render() expect(term.written).toEqual(['line-A', 'line-B']) }) it('read-only: the preview terminal exposes NO input path (no onData/send)', async () => { const card = document.createElement('div') const term = mockTerminal() mountPreviewClient( card, sealedReplay(['x']), HOST_SECRET, { cols: 80, rows: 24 }, { ...realDeps, createTerminal: () => term }, ).render() expect('onData' in term).toBe(false) expect('send' in term).toBe(false) }) it('never writes hostContentSecret to localStorage/console', async () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) const card = document.createElement('div') await mountPreviewClient( card, sealedReplay(['x']), HOST_SECRET, { cols: 80, rows: 24 }, { ...realDeps, createTerminal: () => mockTerminal() }, ).render() expect(spy).not.toHaveBeenCalled() expect(JSON.stringify(localStorage)).not.toContain( Array.from(HOST_SECRET.slice(0, 4)).join(','), ) spy.mockRestore() }) })