tracegist-mcp-bridge 0.3.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,94 @@
2
2
 
3
3
  All notable changes to `tracegist-mcp-bridge`.
4
4
 
5
+ ## 1.0.0
6
+
7
+ > ⚠️ **Breaking Remote Live Session protocol change** (`FRAME_VERSION` → 3):
8
+ > a 1.0.0 bridge will NOT interoperate with an older extension, and vice
9
+ > versa. It ships **in lockstep** with the TraceGist extension v1.0.0 release —
10
+ > update both together. Includes everything from the (unpublished) 0.5.0 below.
11
+
12
+ Hardens the E2E encryption per the security audit (findings F1–F3) plus a
13
+ per-connection ephemeral-key handshake:
14
+
15
+ - **Per-connection handshake (fixes the CRITICAL reconnect/replay flaw).** Data
16
+ frames are now encrypted under a _connection key_, not the code key. On every
17
+ (re)connection the tester sends a fresh nonce (tagged with a monotonic
18
+ wall-clock epoch); the agent replies with its own nonce; both HKDF a connection
19
+ key from the code secret + both nonces + epoch and reset their counter. Because
20
+ each connection derives a distinct key: (a) a routine single-sided reconnect
21
+ re-handshakes and resynchronizes both sides (no more "post-reconnect frames
22
+ rejected as replays" brick), and (b) a malicious relay replaying a previous
23
+ connection's frames fails to decrypt under the new key. The agent is idempotent
24
+ per epoch and the tester retransmits until it gets a reply (with a bounded
25
+ timeout that ends the session), so a dropped handshake nonce recovers.
26
+ - **Anti-replay / anti-reflection (F1).** Every data frame authenticates a
27
+ per-direction monotonic counter + sender role via AES-GCM AAD; a `ReplayGuard`
28
+ rejects non-increasing counters (in-connection replay/reorder), and the
29
+ per-connection key rejects cross-connection replay.
30
+ - **Slow KDF (F2).** The code secret is PBKDF2-SHA256 (210k iterations); the
31
+ handshake and connection keys are HKDF'd from it. The relay-visible room ID
32
+ stays a fast, domain-separated hash.
33
+ - **Chunk-count cap (F3).** `FrameChunkAssembler` rejects a part count above
34
+ `MAX_CHUNK_TOTAL` (1024).
35
+
36
+ Not included (deferred): **forward secrecy (F5)** — the nonce-mixed key does NOT
37
+ provide it (a compromised code + logged nonces still derives the key); true FS
38
+ needs ephemeral ECDH. Also deferred: raising session-code entropy above 75 bits.
39
+
40
+ ## 0.5.0
41
+
42
+ _Never published to npm — these changes first shipped as part of 1.0.0._
43
+
44
+ Remote Live Session agent-experience improvements (all additive — no breaking
45
+ tool-signature changes), from real agent-session feedback.
46
+
47
+ - `watch_live_session` gains an optional `wait_seconds` long-poll parameter (max
48
+ 30). The call now blocks until the next event arrives (or the session ends),
49
+ so agents react instantly instead of sleeping-and-re-polling every few seconds.
50
+ - **Tester reconnect grace window.** A transient tester disconnect (service-worker
51
+ suspend, network blip) no longer ends the session immediately; the bridge waits
52
+ `TRACEGIST_PEER_GRACE_MS` (default 15s) for the extension to reconnect and
53
+ re-announce, preserving buffered events and in-flight replies. An explicit
54
+ `session-end` (recording stopped / consent revoked) still ends at once.
55
+ - **Actionable, differentiated errors** for `ask_tester_question` /
56
+ `get_live_screenshot`: distinguishes "tester hasn't joined / left", "connected
57
+ but not recording yet", and "momentarily disconnected, retry" instead of one
58
+ opaque message.
59
+ - **Silent-recording detection.** A voice answer that transcribes to whisper's
60
+ `[BLANK_AUDIO]` (or empty) is now surfaced as an explicit "no speech detected —
61
+ consider re-asking" signal rather than a bogus transcript.
62
+ - Every live event now carries `tsEpoch` (bridge wall-clock at ingestion) — a
63
+ single uniformly-comparable absolute timestamp, since `ts` is source-relative
64
+ for tester events but epoch for bridge control events.
65
+ - `ask_tester_question` message limit raised 200 → 500 chars and reframed to allow
66
+ statements/replies, not only questions.
67
+
68
+ ## 0.4.0
69
+
70
+ **Remote Live Sessions** — the live shadowing loop now works across machines.
71
+ A tester anywhere starts a remote session in the TraceGist extension and
72
+ shares a code; this bridge joins through a relay and every live tool works
73
+ exactly as it does locally.
74
+
75
+ - New tool `join_live_session({ code })`: joins the tester's session via a
76
+ relay (`TRACEGIST_RELAY_URL` env var overrides the default). All live-shadow
77
+ traffic is end-to-end encrypted with AES-256-GCM using a key derived from
78
+ the session code — the relay only ever sees ciphertext, and the room ID is
79
+ a hash of the code, so the relay cannot derive the key.
80
+ - New tool `leave_live_session()`: disconnects from the remote session.
81
+ - `live-session://status` now reports the active transport (`local` / `relay`
82
+ / `none`) and remote connection state.
83
+ - The local localhost WebSocket transport is unchanged and remains the
84
+ default; remote mode is purely additive (semver minor).
85
+ - `TRACEGIST_DISABLE_LIVE` now also disables `join_live_session`.
86
+ - Relay reconnects use the same backoff strategy as the extension; a
87
+ `tester-connected` / `tester-disconnected` live event is pushed when the
88
+ tester's extension joins or drops.
89
+
90
+ The relay itself ships separately as the `tracegist-relay` package
91
+ (self-hostable; plain Node + ws).
92
+
5
93
  ## 0.3.0
6
94
 
7
95
  **Breaking changes:**
package/bin/lib.mjs CHANGED
@@ -212,6 +212,44 @@ export function audioFormatFromMime(mimeType, fallback = "webm") {
212
212
  return m ? m[1] : fallback;
213
213
  }
214
214
 
215
+ /**
216
+ * Interpret a raw transcription string from Whisper/OpenRouter. whisper.cpp
217
+ * emits the literal "[BLANK_AUDIO]" marker (and models can return near-empty
218
+ * text) when a recording has no speech — a silent mic. Strips those markers and
219
+ * reports silence explicitly so the caller can tell the agent to re-ask instead
220
+ * of forwarding a bogus "answer".
221
+ * @param {string | null | undefined} rawText
222
+ * @returns {{ transcription: string } | { silent: true }}
223
+ */
224
+ export function interpretTranscription(rawText) {
225
+ const cleaned = String(rawText || "")
226
+ .replace(/\[BLANK_?AUDIO\]/gi, "")
227
+ .trim();
228
+ if (!cleaned) return { silent: true };
229
+ return { transcription: cleaned };
230
+ }
231
+
232
+ /**
233
+ * Validate a Whisper language code before it is handed to nodejs-whisper.
234
+ *
235
+ * nodejs-whisper interpolates this value UNescaped into a shell command string
236
+ * (`-l <language>`) executed via shelljs.exec, so an unvalidated value is a
237
+ * command-injection sink. Whisper language codes are ISO 639-1/639-2 (2-3
238
+ * ASCII letters) or the literal "auto". Returns the trimmed code, or `undefined`
239
+ * for an empty/absent value. Throws on anything else.
240
+ */
241
+ export function sanitizeWhisperLanguage(language) {
242
+ const trimmed = typeof language === "string" ? language.trim() : "";
243
+ if (!trimmed) return undefined;
244
+ if (!/^(auto|[a-z]{2,3})$/i.test(trimmed)) {
245
+ throw new Error(
246
+ `Invalid language code: ${JSON.stringify(language)}. ` +
247
+ `Expected an ISO 639 code (e.g. "en", "de", "ja") or "auto".`,
248
+ );
249
+ }
250
+ return trimmed;
251
+ }
252
+
215
253
  /**
216
254
  * Parse a boolean-ish env-var value. Treats "0", "false", "no", "off"
217
255
  * (case-insensitive) and empty/undefined as falsy; everything else as truthy.
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Remote Live Sessions — session-code derivation and E2E frame crypto.
3
+ *
4
+ * Node-side mirror of the extension's lib/remote-session.ts. The two modules
5
+ * must stay byte-compatible: same code normalization, same room-ID and key
6
+ * derivation, same AES-256-GCM frame format (see docs/live-protocol.md in the
7
+ * TraceGist repo). This package is published standalone, so the primitives
8
+ * are duplicated here rather than imported from the extension codebase.
9
+ */
10
+
11
+ import { webcrypto } from "node:crypto";
12
+
13
+ const subtle = webcrypto.subtle;
14
+
15
+ /** Crockford base32 alphabet — no I, L, O, U to avoid ambiguity. */
16
+ const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
17
+ const CODE_GROUP_LENGTH = 5;
18
+ const CODE_GROUPS = 3;
19
+
20
+ const ROOM_ID_CONTEXT = "tracegist-room-v1:";
21
+
22
+ /**
23
+ * Key derivation: PBKDF2-SHA256, high iteration count, into an HKDF base key.
24
+ * v3 adds the per-connection ephemeral-key handshake (see HandshakeManager).
25
+ * MUST match lib/remote-session.ts.
26
+ */
27
+ const PBKDF2_SALT = "tracegist-live-v2";
28
+ const PBKDF2_ITERATIONS = 210_000;
29
+
30
+ export const FRAME_VERSION = 3;
31
+
32
+ const HKDF_HANDSHAKE_INFO = "tracegist-hs-key-v3";
33
+ const HKDF_CONNECTION_INFO = "tracegist-conn-v3";
34
+ const HKDF_SALT_CONST = "tracegist-live-v3";
35
+ const HANDSHAKE_NONCE_BYTES = 16;
36
+
37
+ /** AES-GCM IV length in bytes (protocol constant, see docs/live-protocol.md) */
38
+ const GCM_IV_BYTES = 12;
39
+
40
+ /**
41
+ * AAD bound into every DATA frame's AES-GCM tag: version + sender role +
42
+ * per-direction counter. Blocks reflection and replay/reorder. MUST match
43
+ * lib/remote-session.ts byte-for-byte.
44
+ * @param {"tester" | "agent"} senderRole
45
+ * @param {number} counter
46
+ */
47
+ function frameAad(senderRole, counter) {
48
+ return new TextEncoder().encode(`tracegist-frame-v3|${senderRole}|${counter}`);
49
+ }
50
+
51
+ /** AAD for a handshake frame: binds protocol tag + sender role. */
52
+ function handshakeAad(senderRole) {
53
+ return new TextEncoder().encode(`tracegist-hs-v3|${senderRole}`);
54
+ }
55
+
56
+ /**
57
+ * Rejects frames whose per-direction counter is not strictly increasing (anti
58
+ * replay/reorder). One instance per receive direction; reset per connection.
59
+ */
60
+ export class ReplayGuard {
61
+ constructor() {
62
+ this.last = -1;
63
+ }
64
+ /** @param {number} counter */
65
+ accept(counter) {
66
+ if (!Number.isInteger(counter) || counter <= this.last) return false;
67
+ this.last = counter;
68
+ return true;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Normalize a user-entered session code: trim, uppercase.
74
+ * @param {string} raw
75
+ * @returns {string | null} normalized code, or null if malformed
76
+ */
77
+ export function normalizeSessionCode(raw) {
78
+ const code = String(raw).trim().toUpperCase();
79
+ const groupPattern = `[${CODE_ALPHABET}]{${CODE_GROUP_LENGTH}}`;
80
+ const pattern = new RegExp(`^TG(-${groupPattern}){${CODE_GROUPS}}$`);
81
+ return pattern.test(code) ? code : null;
82
+ }
83
+
84
+ /**
85
+ * Derive the relay room ID (hex SHA-256) from a normalized session code.
86
+ * @param {string} code
87
+ * @returns {Promise<string>}
88
+ */
89
+ export async function deriveRoomId(code) {
90
+ const data = new TextEncoder().encode(ROOM_ID_CONTEXT + code);
91
+ const digest = await subtle.digest("SHA-256", data);
92
+ return Buffer.from(digest).toString("hex");
93
+ }
94
+
95
+ /**
96
+ * Derive the long-term "code secret" (HKDF base key) from the session code via
97
+ * PBKDF2. Everything else is HKDF'd from this. MUST match lib/remote-session.ts.
98
+ * @param {string} code
99
+ * @returns {Promise<CryptoKey>}
100
+ */
101
+ export async function deriveCodeSecret(code) {
102
+ const encoder = new TextEncoder();
103
+ const pbkdf2Key = await subtle.importKey("raw", encoder.encode(code), "PBKDF2", false, [
104
+ "deriveBits",
105
+ ]);
106
+ const bits = await subtle.deriveBits(
107
+ {
108
+ name: "PBKDF2",
109
+ hash: "SHA-256",
110
+ salt: encoder.encode(PBKDF2_SALT),
111
+ iterations: PBKDF2_ITERATIONS,
112
+ },
113
+ pbkdf2Key,
114
+ 256,
115
+ );
116
+ return subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
117
+ }
118
+
119
+ /**
120
+ * Derive the handshake key (encrypts the nonce-exchange frames).
121
+ * @param {CryptoKey} codeSecret
122
+ * @returns {Promise<CryptoKey>}
123
+ */
124
+ export async function deriveHandshakeKey(codeSecret) {
125
+ const encoder = new TextEncoder();
126
+ return subtle.deriveKey(
127
+ {
128
+ name: "HKDF",
129
+ hash: "SHA-256",
130
+ salt: encoder.encode(HKDF_SALT_CONST),
131
+ info: encoder.encode(HKDF_HANDSHAKE_INFO),
132
+ },
133
+ codeSecret,
134
+ { name: "AES-GCM", length: 256 },
135
+ false,
136
+ ["encrypt", "decrypt"],
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Derive the per-connection DATA key from the code secret + both handshake
142
+ * nonces + epoch (nonces concatenated tester-then-agent). MUST match
143
+ * lib/remote-session.ts.
144
+ * @param {CryptoKey} codeSecret
145
+ * @param {Uint8Array} testerNonce
146
+ * @param {Uint8Array} agentNonce
147
+ * @param {number} epoch
148
+ * @returns {Promise<CryptoKey>}
149
+ */
150
+ export async function deriveConnectionKey(codeSecret, testerNonce, agentNonce, epoch) {
151
+ const salt = new Uint8Array(testerNonce.length + agentNonce.length);
152
+ salt.set(testerNonce, 0);
153
+ salt.set(agentNonce, testerNonce.length);
154
+ return subtle.deriveKey(
155
+ {
156
+ name: "HKDF",
157
+ hash: "SHA-256",
158
+ salt,
159
+ info: new TextEncoder().encode(`${HKDF_CONNECTION_INFO}|${epoch}`),
160
+ },
161
+ codeSecret,
162
+ { name: "AES-GCM", length: 256 },
163
+ false,
164
+ ["encrypt", "decrypt"],
165
+ );
166
+ }
167
+
168
+ /** Generate a fresh handshake nonce (raw bytes). */
169
+ export function generateHandshakeNonce() {
170
+ return webcrypto.getRandomValues(new Uint8Array(HANDSHAKE_NONCE_BYTES));
171
+ }
172
+
173
+ /**
174
+ * Encrypt a live-shadow message into a frame payload. `senderRole` is this
175
+ * endpoint's own role, `counter` its per-direction monotonic sequence; both are
176
+ * authenticated via AAD.
177
+ * @param {CryptoKey} key
178
+ * @param {object} message
179
+ * @param {"tester" | "agent"} senderRole
180
+ * @param {number} counter
181
+ * @returns {Promise<{v: number, iv: string, ct: string, c: number}>}
182
+ */
183
+ export async function encryptFrame(key, message, senderRole, counter) {
184
+ // Counter must be a non-negative safe integer (see lib/remote-session.ts).
185
+ if (!Number.isSafeInteger(counter) || counter < 0) {
186
+ throw new Error("Frame counter exhausted — a new session/key is required");
187
+ }
188
+ const iv = webcrypto.getRandomValues(new Uint8Array(GCM_IV_BYTES));
189
+ const plaintext = new TextEncoder().encode(JSON.stringify(message));
190
+ const additionalData = frameAad(senderRole, counter);
191
+ const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, plaintext);
192
+ return {
193
+ v: FRAME_VERSION,
194
+ iv: Buffer.from(iv).toString("base64"),
195
+ ct: Buffer.from(ct).toString("base64"),
196
+ c: counter,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Decrypt a frame payload back into a message object. `senderRole` is the role
202
+ * the receiver EXPECTS (the peer's role); the AAD is rebuilt from it and the
203
+ * frame counter, so a reflected or counter-tampered frame fails to authenticate.
204
+ * Returns null for unknown versions, malformed payloads, or failed auth.
205
+ * Anti-replay is enforced separately by ReplayGuard.
206
+ * @param {CryptoKey} key
207
+ * @param {{v: number, iv: string, ct: string, c: number}} payload
208
+ * @param {"tester" | "agent"} senderRole
209
+ * @returns {Promise<object | null>}
210
+ */
211
+ export async function decryptFrame(key, payload, senderRole) {
212
+ // Counters are non-negative safe integers by construction; reject anything
213
+ // else before it reaches AAD building or the ReplayGuard.
214
+ if (!payload || payload.v !== FRAME_VERSION || !Number.isSafeInteger(payload.c) || payload.c < 0)
215
+ return null;
216
+ try {
217
+ const iv = Buffer.from(payload.iv, "base64");
218
+ const ct = Buffer.from(payload.ct, "base64");
219
+ const additionalData = frameAad(senderRole, payload.c);
220
+ const plaintext = await subtle.decrypt({ name: "AES-GCM", iv, additionalData }, key, ct);
221
+ return JSON.parse(new TextDecoder().decode(plaintext));
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Encrypt a handshake message under the handshake key (authenticated by role).
229
+ * @param {CryptoKey} handshakeKey
230
+ * @param {"tester" | "agent"} senderRole
231
+ * @param {{epoch: number, nonce: string, role: string}} message
232
+ * @returns {Promise<{hs: 1, iv: string, ct: string}>}
233
+ */
234
+ export async function encryptHandshake(handshakeKey, senderRole, message) {
235
+ const iv = webcrypto.getRandomValues(new Uint8Array(GCM_IV_BYTES));
236
+ const plaintext = new TextEncoder().encode(JSON.stringify(message));
237
+ const additionalData = handshakeAad(senderRole);
238
+ const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData }, handshakeKey, plaintext);
239
+ return {
240
+ hs: 1,
241
+ iv: Buffer.from(iv).toString("base64"),
242
+ ct: Buffer.from(ct).toString("base64"),
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Decrypt a handshake frame. `senderRole` is the expected sender (peer) role.
248
+ * @param {CryptoKey} handshakeKey
249
+ * @param {"tester" | "agent"} senderRole
250
+ * @param {{hs: 1, iv: string, ct: string}} payload
251
+ * @returns {Promise<{epoch: number, nonce: string, role: string} | null>}
252
+ */
253
+ export async function decryptHandshake(handshakeKey, senderRole, payload) {
254
+ if (!payload || payload.hs !== 1) return null;
255
+ try {
256
+ const iv = Buffer.from(payload.iv, "base64");
257
+ const ct = Buffer.from(payload.ct, "base64");
258
+ const additionalData = handshakeAad(senderRole);
259
+ const plaintext = await subtle.decrypt(
260
+ { name: "AES-GCM", iv, additionalData },
261
+ handshakeKey,
262
+ ct,
263
+ );
264
+ const msg = JSON.parse(new TextDecoder().decode(plaintext));
265
+ if (
266
+ !Number.isSafeInteger(msg.epoch) ||
267
+ msg.epoch < 0 ||
268
+ typeof msg.nonce !== "string" ||
269
+ (msg.role !== "tester" && msg.role !== "agent")
270
+ ) {
271
+ return null;
272
+ }
273
+ return msg;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ /** True if a relay payload is a handshake frame (vs a data frame). */
280
+ export function isHandshakeFrame(payload) {
281
+ return typeof payload === "object" && payload !== null && payload.hs === 1;
282
+ }
283
+
284
+ /**
285
+ * Per-connection ephemeral-key handshake state machine — mirror of
286
+ * lib/remote-session.ts (see that file for the protocol description).
287
+ */
288
+ export class HandshakeManager {
289
+ /**
290
+ * @param {"tester" | "agent"} role
291
+ * @param {CryptoKey} codeSecret
292
+ * @param {CryptoKey} handshakeKey
293
+ */
294
+ constructor(role, codeSecret, handshakeKey) {
295
+ this.role = role;
296
+ this.peerRole = role === "tester" ? "agent" : "tester";
297
+ this.codeSecret = codeSecret;
298
+ this.handshakeKey = handshakeKey;
299
+ this.connectionKey = null;
300
+ this.sendCounter = 0;
301
+ this.guard = new ReplayGuard();
302
+ this.pendingEpoch = -1;
303
+ this.pendingNonce = null;
304
+ this.lastEpoch = -1;
305
+ // Highest epoch the tester has established (idempotency guard; see
306
+ // lib/remote-session.ts).
307
+ this.establishedEpoch = -1;
308
+ this.respondedEpoch = -1;
309
+ this.respondedNonce = null;
310
+ }
311
+
312
+ /**
313
+ * @param {"tester" | "agent"} role
314
+ * @param {string} code
315
+ */
316
+ static async create(role, code) {
317
+ const codeSecret = await deriveCodeSecret(code);
318
+ const handshakeKey = await deriveHandshakeKey(codeSecret);
319
+ return new HandshakeManager(role, codeSecret, handshakeKey);
320
+ }
321
+
322
+ get ready() {
323
+ return this.connectionKey !== null;
324
+ }
325
+
326
+ /** @param {number} now */
327
+ nextEpoch(now) {
328
+ const e = Math.max(this.lastEpoch + 1, Math.floor(now));
329
+ this.lastEpoch = e;
330
+ return e;
331
+ }
332
+
333
+ /** TESTER: begin a fresh handshake. @param {number} now */
334
+ async startHandshake(now) {
335
+ if (this.role !== "tester") throw new Error("only the tester initiates the handshake");
336
+ this.connectionKey = null;
337
+ this.pendingEpoch = this.nextEpoch(now);
338
+ this.pendingNonce = generateHandshakeNonce();
339
+ return this.encryptPendingHandshake();
340
+ }
341
+
342
+ encryptPendingHandshake() {
343
+ return encryptHandshake(this.handshakeKey, "tester", {
344
+ epoch: this.pendingEpoch,
345
+ nonce: Buffer.from(this.pendingNonce).toString("base64"),
346
+ role: "tester",
347
+ });
348
+ }
349
+
350
+ /** TESTER: re-encrypt the in-flight handshake for retransmit (or null). */
351
+ async retransmit() {
352
+ if (this.role !== "tester" || this.connectionKey !== null || !this.pendingNonce) return null;
353
+ return this.encryptPendingHandshake();
354
+ }
355
+
356
+ /** Feed an incoming handshake frame. */
357
+ async onHandshakeFrame(payload) {
358
+ const msg = await decryptHandshake(this.handshakeKey, this.peerRole, payload);
359
+ if (!msg || msg.role !== this.peerRole) return { kind: "ignored" };
360
+
361
+ if (this.role === "agent") {
362
+ if (msg.epoch < this.respondedEpoch) return { kind: "ignored" };
363
+ if (msg.epoch === this.respondedEpoch && this.respondedNonce) {
364
+ const resend = await encryptHandshake(this.handshakeKey, "agent", {
365
+ epoch: msg.epoch,
366
+ nonce: Buffer.from(this.respondedNonce).toString("base64"),
367
+ role: "agent",
368
+ });
369
+ return { kind: "response", payload: resend, keyReady: this.connectionKey !== null };
370
+ }
371
+ const testerNonce = Buffer.from(msg.nonce, "base64");
372
+ // A wrong-size nonce would silently derive a mismatched key — fail loud.
373
+ if (testerNonce.length !== HANDSHAKE_NONCE_BYTES) return { kind: "ignored" };
374
+ const agentNonce = generateHandshakeNonce();
375
+ this.connectionKey = await deriveConnectionKey(
376
+ this.codeSecret,
377
+ testerNonce,
378
+ agentNonce,
379
+ msg.epoch,
380
+ );
381
+ this.sendCounter = 0;
382
+ this.guard = new ReplayGuard();
383
+ this.respondedEpoch = msg.epoch;
384
+ this.respondedNonce = agentNonce;
385
+ const response = await encryptHandshake(this.handshakeKey, "agent", {
386
+ epoch: msg.epoch,
387
+ nonce: Buffer.from(agentNonce).toString("base64"),
388
+ role: "agent",
389
+ });
390
+ return { kind: "response", payload: response, keyReady: true };
391
+ }
392
+
393
+ if (msg.epoch !== this.pendingEpoch || !this.pendingNonce) return { kind: "ignored" };
394
+ // Idempotent: a re-delivered/replayed response for an already-established
395
+ // epoch must not re-derive or reset (would rewind the counter + reopen the
396
+ // replay hole). A legitimate reconnect uses a strictly higher epoch.
397
+ if (this.establishedEpoch >= msg.epoch) return { kind: "ignored" };
398
+ const agentNonce = Buffer.from(msg.nonce, "base64");
399
+ // A wrong-size nonce would silently derive a mismatched key — fail loud.
400
+ if (agentNonce.length !== HANDSHAKE_NONCE_BYTES) return { kind: "ignored" };
401
+ this.connectionKey = await deriveConnectionKey(
402
+ this.codeSecret,
403
+ this.pendingNonce,
404
+ agentNonce,
405
+ msg.epoch,
406
+ );
407
+ this.sendCounter = 0;
408
+ this.guard = new ReplayGuard();
409
+ this.establishedEpoch = msg.epoch;
410
+ return { kind: "established" };
411
+ }
412
+
413
+ /** Encrypt a data message under the connection key. Throws if not ready. */
414
+ async encryptData(message) {
415
+ if (!this.connectionKey) throw new Error("handshake not complete");
416
+ return encryptFrame(this.connectionKey, message, this.role, this.sendCounter++);
417
+ }
418
+
419
+ /** Decrypt + replay-check a data frame. Null if not ready / invalid. */
420
+ async decryptData(payload) {
421
+ if (!this.connectionKey) return null;
422
+ const inner = await decryptFrame(this.connectionKey, payload, this.peerRole);
423
+ if (inner === null) return null;
424
+ if (!this.guard.accept(payload.c)) return null;
425
+ return inner;
426
+ }
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Frame chunking — mirror of lib/remote-session.ts (see that file for the
431
+ // size rationale). Hosted relays cap WebSocket messages (Cloudflare: 1 MiB);
432
+ // oversized inner messages are split into `frame-chunk` parts BEFORE
433
+ // encryption so relays only ever see ordinary frames.
434
+ // ---------------------------------------------------------------------------
435
+
436
+ /** Inner messages whose UTF-8 size exceeds this are chunked. */
437
+ export const FRAME_CHUNK_THRESHOLD_BYTES = 700_000;
438
+ /** UTF-8 bytes per chunk. */
439
+ export const FRAME_CHUNK_BYTES = 500_000;
440
+ /** Hard cap on part count — bounds new Array(total) from a peer-supplied total. */
441
+ export const MAX_CHUNK_TOTAL = 1024;
442
+
443
+ /**
444
+ * Split an oversized inner message into `frame-chunk` parts. Returns null
445
+ * when the message fits in a single frame. Operates on UTF-8 bytes so
446
+ * multi-byte characters and surrogate pairs can never be split.
447
+ * @param {object} message
448
+ * @returns {Array<{type: "frame-chunk", id: string, seq: number, total: number, data: string}> | null}
449
+ */
450
+ export function chunkInnerMessage(message) {
451
+ const bytes = new TextEncoder().encode(JSON.stringify(message));
452
+ if (bytes.length <= FRAME_CHUNK_THRESHOLD_BYTES) return null;
453
+ const total = Math.ceil(bytes.length / FRAME_CHUNK_BYTES);
454
+ const id = webcrypto.randomUUID();
455
+ const parts = [];
456
+ for (let seq = 0; seq < total; seq++) {
457
+ parts.push({
458
+ type: "frame-chunk",
459
+ id,
460
+ seq,
461
+ total,
462
+ data: Buffer.from(
463
+ bytes.subarray(seq * FRAME_CHUNK_BYTES, (seq + 1) * FRAME_CHUNK_BYTES),
464
+ ).toString("base64"),
465
+ });
466
+ }
467
+ return parts;
468
+ }
469
+
470
+ /**
471
+ * Reassembles decrypted `frame-chunk` parts back into the original message.
472
+ * Non-chunk messages pass through unchanged; chunk parts return null until
473
+ * the final part completes the message. A part for a new id discards any
474
+ * incomplete assembly (peer reconnect mid-message).
475
+ */
476
+ export class FrameChunkAssembler {
477
+ constructor() {
478
+ this.id = null;
479
+ this.parts = [];
480
+ this.received = 0;
481
+ }
482
+
483
+ /**
484
+ * @param {object} inner - a decrypted inner message
485
+ * @returns {object | null} pass-through message, completed reassembly, or null
486
+ */
487
+ push(inner) {
488
+ if (inner.type !== "frame-chunk") return inner;
489
+
490
+ const { id, seq, total, data } = inner;
491
+ if (
492
+ typeof id !== "string" ||
493
+ typeof data !== "string" ||
494
+ !Number.isInteger(seq) ||
495
+ !Number.isInteger(total) ||
496
+ total < 1 ||
497
+ total > MAX_CHUNK_TOTAL ||
498
+ seq < 0 ||
499
+ seq >= total
500
+ ) {
501
+ return null;
502
+ }
503
+
504
+ if (this.id !== id) {
505
+ this.id = id;
506
+ this.parts = new Array(total);
507
+ this.received = 0;
508
+ }
509
+
510
+ if (this.parts.length !== total || this.parts[seq] !== undefined) {
511
+ this.reset();
512
+ return null;
513
+ }
514
+
515
+ this.parts[seq] = data;
516
+ this.received++;
517
+ if (this.received < total) return null;
518
+
519
+ const assembled = this.parts;
520
+ this.reset();
521
+ try {
522
+ const bytes = Buffer.concat(assembled.map((p) => Buffer.from(p, "base64")));
523
+ return JSON.parse(new TextDecoder().decode(bytes));
524
+ } catch {
525
+ // Corrupt part payload — same posture as decryptFrame: drop, don't crash
526
+ return null;
527
+ }
528
+ }
529
+
530
+ reset() {
531
+ this.id = null;
532
+ this.parts = [];
533
+ this.received = 0;
534
+ }
535
+ }