tracegist-mcp-bridge 0.4.0 → 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 +63 -0
- package/bin/lib.mjs +38 -0
- package/bin/live-crypto.mjs +341 -20
- package/bin/tracegist-mcp-bridge.mjs +253 -40
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,69 @@
|
|
|
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
|
+
|
|
5
68
|
## 0.4.0
|
|
6
69
|
|
|
7
70
|
**Remote Live Sessions** — the live shadowing loop now works across machines.
|
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.
|
package/bin/live-crypto.mjs
CHANGED
|
@@ -18,14 +18,57 @@ const CODE_GROUP_LENGTH = 5;
|
|
|
18
18
|
const CODE_GROUPS = 3;
|
|
19
19
|
|
|
20
20
|
const ROOM_ID_CONTEXT = "tracegist-room-v1:";
|
|
21
|
-
const HKDF_SALT = "tracegist-live-v1";
|
|
22
|
-
const HKDF_INFO = "aes-256-gcm";
|
|
23
21
|
|
|
24
|
-
|
|
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;
|
|
25
36
|
|
|
26
37
|
/** AES-GCM IV length in bytes (protocol constant, see docs/live-protocol.md) */
|
|
27
38
|
const GCM_IV_BYTES = 12;
|
|
28
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
|
+
|
|
29
72
|
/**
|
|
30
73
|
* Normalize a user-entered session code: trim, uppercase.
|
|
31
74
|
* @param {string} raw
|
|
@@ -50,21 +93,44 @@ export async function deriveRoomId(code) {
|
|
|
50
93
|
}
|
|
51
94
|
|
|
52
95
|
/**
|
|
53
|
-
* Derive the
|
|
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.
|
|
54
98
|
* @param {string} code
|
|
55
99
|
* @returns {Promise<CryptoKey>}
|
|
56
100
|
*/
|
|
57
|
-
export async function
|
|
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) {
|
|
58
125
|
const encoder = new TextEncoder();
|
|
59
|
-
const ikm = await subtle.importKey("raw", encoder.encode(code), "HKDF", false, ["deriveKey"]);
|
|
60
126
|
return subtle.deriveKey(
|
|
61
127
|
{
|
|
62
128
|
name: "HKDF",
|
|
63
129
|
hash: "SHA-256",
|
|
64
|
-
salt: encoder.encode(
|
|
65
|
-
info: encoder.encode(
|
|
130
|
+
salt: encoder.encode(HKDF_SALT_CONST),
|
|
131
|
+
info: encoder.encode(HKDF_HANDSHAKE_INFO),
|
|
66
132
|
},
|
|
67
|
-
|
|
133
|
+
codeSecret,
|
|
68
134
|
{ name: "AES-GCM", length: 256 },
|
|
69
135
|
false,
|
|
70
136
|
["encrypt", "decrypt"],
|
|
@@ -72,42 +138,294 @@ export async function deriveSessionKey(code) {
|
|
|
72
138
|
}
|
|
73
139
|
|
|
74
140
|
/**
|
|
75
|
-
*
|
|
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.
|
|
76
177
|
* @param {CryptoKey} key
|
|
77
178
|
* @param {object} message
|
|
78
|
-
* @
|
|
179
|
+
* @param {"tester" | "agent"} senderRole
|
|
180
|
+
* @param {number} counter
|
|
181
|
+
* @returns {Promise<{v: number, iv: string, ct: string, c: number}>}
|
|
79
182
|
*/
|
|
80
|
-
export async function encryptFrame(key, message) {
|
|
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
|
+
}
|
|
81
188
|
const iv = webcrypto.getRandomValues(new Uint8Array(GCM_IV_BYTES));
|
|
82
189
|
const plaintext = new TextEncoder().encode(JSON.stringify(message));
|
|
83
|
-
const
|
|
190
|
+
const additionalData = frameAad(senderRole, counter);
|
|
191
|
+
const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, plaintext);
|
|
84
192
|
return {
|
|
85
193
|
v: FRAME_VERSION,
|
|
86
194
|
iv: Buffer.from(iv).toString("base64"),
|
|
87
195
|
ct: Buffer.from(ct).toString("base64"),
|
|
196
|
+
c: counter,
|
|
88
197
|
};
|
|
89
198
|
}
|
|
90
199
|
|
|
91
200
|
/**
|
|
92
|
-
* Decrypt a frame payload back into a message object.
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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.
|
|
95
206
|
* @param {CryptoKey} key
|
|
96
|
-
* @param {{v: number, iv: string, ct: string}} payload
|
|
207
|
+
* @param {{v: number, iv: string, ct: string, c: number}} payload
|
|
208
|
+
* @param {"tester" | "agent"} senderRole
|
|
97
209
|
* @returns {Promise<object | null>}
|
|
98
210
|
*/
|
|
99
|
-
export async function decryptFrame(key, payload) {
|
|
100
|
-
|
|
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;
|
|
101
216
|
try {
|
|
102
217
|
const iv = Buffer.from(payload.iv, "base64");
|
|
103
218
|
const ct = Buffer.from(payload.ct, "base64");
|
|
104
|
-
const
|
|
219
|
+
const additionalData = frameAad(senderRole, payload.c);
|
|
220
|
+
const plaintext = await subtle.decrypt({ name: "AES-GCM", iv, additionalData }, key, ct);
|
|
105
221
|
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
106
222
|
} catch {
|
|
107
223
|
return null;
|
|
108
224
|
}
|
|
109
225
|
}
|
|
110
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
|
+
|
|
111
429
|
// ---------------------------------------------------------------------------
|
|
112
430
|
// Frame chunking — mirror of lib/remote-session.ts (see that file for the
|
|
113
431
|
// size rationale). Hosted relays cap WebSocket messages (Cloudflare: 1 MiB);
|
|
@@ -119,6 +437,8 @@ export async function decryptFrame(key, payload) {
|
|
|
119
437
|
export const FRAME_CHUNK_THRESHOLD_BYTES = 700_000;
|
|
120
438
|
/** UTF-8 bytes per chunk. */
|
|
121
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;
|
|
122
442
|
|
|
123
443
|
/**
|
|
124
444
|
* Split an oversized inner message into `frame-chunk` parts. Returns null
|
|
@@ -174,6 +494,7 @@ export class FrameChunkAssembler {
|
|
|
174
494
|
!Number.isInteger(seq) ||
|
|
175
495
|
!Number.isInteger(total) ||
|
|
176
496
|
total < 1 ||
|
|
497
|
+
total > MAX_CHUNK_TOTAL ||
|
|
177
498
|
seq < 0 ||
|
|
178
499
|
seq >= total
|
|
179
500
|
) {
|
|
@@ -25,13 +25,14 @@ import {
|
|
|
25
25
|
audioFormatFromMime,
|
|
26
26
|
isAllowedLiveShadowOrigin,
|
|
27
27
|
envFlagEnabled,
|
|
28
|
+
interpretTranscription,
|
|
29
|
+
sanitizeWhisperLanguage,
|
|
28
30
|
} from "./lib.mjs";
|
|
29
31
|
import {
|
|
30
32
|
normalizeSessionCode,
|
|
31
33
|
deriveRoomId,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
decryptFrame,
|
|
34
|
+
HandshakeManager,
|
|
35
|
+
isHandshakeFrame,
|
|
35
36
|
chunkInnerMessage,
|
|
36
37
|
FrameChunkAssembler,
|
|
37
38
|
} from "./live-crypto.mjs";
|
|
@@ -175,7 +176,9 @@ const WHISPER_CONCURRENCY = 2;
|
|
|
175
176
|
* accepts the file directly without any ffmpeg hop.
|
|
176
177
|
*/
|
|
177
178
|
async function transcribeWithLocalWhisper(audioPath, model, language) {
|
|
178
|
-
|
|
179
|
+
// Reject anything that is not a plain language code before it reaches the
|
|
180
|
+
// shell — nodejs-whisper interpolates this value unescaped into `-l <lang>`.
|
|
181
|
+
const trimmedLang = sanitizeWhisperLanguage(language);
|
|
179
182
|
const transcript = await nodewhisper(audioPath, {
|
|
180
183
|
modelName: model,
|
|
181
184
|
autoDownloadModelName: model,
|
|
@@ -1095,6 +1098,20 @@ const RELAY_MAX_RECONNECT_ATTEMPTS = 10;
|
|
|
1095
1098
|
const PENDING_QUESTIONS_MAX = 50;
|
|
1096
1099
|
const WATCH_LIVE_DEFAULT_LIMIT = 100;
|
|
1097
1100
|
const WATCH_LIVE_MAX_LIMIT = 500;
|
|
1101
|
+
/** Max seconds watch_live_session will block waiting for a new event (long-poll). */
|
|
1102
|
+
const WATCH_LIVE_MAX_WAIT_SECONDS = 30;
|
|
1103
|
+
/**
|
|
1104
|
+
* How long the bridge keeps a relay session "active" after the tester's peer
|
|
1105
|
+
* drops, giving the tester's extension time to reconnect (it retries for ~30s)
|
|
1106
|
+
* before the session is declared ended. Covers transient service-worker
|
|
1107
|
+
* suspensions / network blips so an in-flight agent reply is not lost to a
|
|
1108
|
+
* momentary disconnect. An explicit `session-end` still ends immediately.
|
|
1109
|
+
* Override via TRACEGIST_PEER_GRACE_MS (0 disables the grace window).
|
|
1110
|
+
*/
|
|
1111
|
+
const PEER_ABSENT_GRACE_MS = (() => {
|
|
1112
|
+
const raw = Number(process.env.TRACEGIST_PEER_GRACE_MS);
|
|
1113
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 15000;
|
|
1114
|
+
})();
|
|
1098
1115
|
|
|
1099
1116
|
/** @type {Array<{seq: number, ts: number, eventType: string, data: object}>} */
|
|
1100
1117
|
const liveEvents = [];
|
|
@@ -1122,6 +1139,20 @@ const liveInteractions = [];
|
|
|
1122
1139
|
/** @type {Array<{resolve: Function, reject: Function, timeout: ReturnType<typeof setTimeout>}>} */
|
|
1123
1140
|
const screenshotWaiters = [];
|
|
1124
1141
|
|
|
1142
|
+
/**
|
|
1143
|
+
* Resolvers for watch_live_session long-poll: each waits for the next event to
|
|
1144
|
+
* be pushed (or a timeout). Notified by pushLiveEvent.
|
|
1145
|
+
* @type {Array<() => void>}
|
|
1146
|
+
*/
|
|
1147
|
+
const eventWaiters = [];
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Timer that ends the relay session after the tester's peer has been absent for
|
|
1151
|
+
* PEER_ABSENT_GRACE_MS. Cleared if the tester reconnects. See peer-left handler.
|
|
1152
|
+
* @type {ReturnType<typeof setTimeout> | null}
|
|
1153
|
+
*/
|
|
1154
|
+
let peerAbsentGraceTimer = null;
|
|
1155
|
+
|
|
1125
1156
|
/**
|
|
1126
1157
|
* Transcribe a voice data URL via the shared transcribeAudio path.
|
|
1127
1158
|
* @param {string} voiceBlobDataUrl - data:audio/...;base64,... URL
|
|
@@ -1133,7 +1164,17 @@ async function transcribeLiveVoice(voiceBlobDataUrl) {
|
|
|
1133
1164
|
const mimeType = match[1];
|
|
1134
1165
|
const audioBuffer = Buffer.from(match[2], "base64");
|
|
1135
1166
|
const result = await transcribeAudio({ audioBuffer, mimeType });
|
|
1136
|
-
|
|
1167
|
+
if (!result.transcription) return { error: result.error };
|
|
1168
|
+
// Detect silent recordings (whisper's "[BLANK_AUDIO]" / empty) and surface them
|
|
1169
|
+
// as an explicit signal instead of handing the agent a bogus "answer".
|
|
1170
|
+
const interpreted = interpretTranscription(result.transcription);
|
|
1171
|
+
if (interpreted.silent) {
|
|
1172
|
+
return {
|
|
1173
|
+
error: "No speech detected — the tester's recording was silent. Consider re-asking.",
|
|
1174
|
+
silent: true,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
return { transcription: interpreted.transcription };
|
|
1137
1178
|
}
|
|
1138
1179
|
|
|
1139
1180
|
/** Push a live event into the ring buffer with auto-incrementing seq.
|
|
@@ -1144,11 +1185,62 @@ function pushLiveEvent(eventType, data, ts = Date.now()) {
|
|
|
1144
1185
|
// Ensure our seq is always higher than the max seq in the buffer
|
|
1145
1186
|
const maxExistingSeq = liveEvents.length > 0 ? liveEvents[liveEvents.length - 1].seq : 0;
|
|
1146
1187
|
liveEventSeq = Math.max(liveEventSeq, maxExistingSeq) + 1;
|
|
1147
|
-
|
|
1188
|
+
// `ts` is the source event time: session-relative ms for extension/tester
|
|
1189
|
+
// events, epoch ms for bridge-originated control events — so it is NOT
|
|
1190
|
+
// consistent across sources. `tsEpoch` is always the bridge's wall-clock at
|
|
1191
|
+
// ingestion, giving agents one uniformly-comparable absolute timestamp.
|
|
1192
|
+
liveEvents.push({ seq: liveEventSeq, ts, tsEpoch: Date.now(), eventType, data });
|
|
1148
1193
|
if (liveEvents.length > LIVE_SHADOW_EVENT_BUFFER_SIZE) {
|
|
1149
1194
|
const evictCount = Math.floor(LIVE_SHADOW_EVENT_BUFFER_SIZE * 0.25);
|
|
1150
1195
|
liveEvents.splice(0, evictCount);
|
|
1151
1196
|
}
|
|
1197
|
+
// Wake any long-poll waiters now that a new event is available.
|
|
1198
|
+
for (const wake of eventWaiters.splice(0)) wake();
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** Cancel a pending "tester absent" grace timer (tester reconnected or session ended). */
|
|
1202
|
+
function clearPeerAbsentGrace() {
|
|
1203
|
+
if (peerAbsentGraceTimer !== null) {
|
|
1204
|
+
clearTimeout(peerAbsentGraceTimer);
|
|
1205
|
+
peerAbsentGraceTimer = null;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Why a tester-directed action (ask_tester_question / get_live_screenshot) can't
|
|
1211
|
+
* run right now, or null if it can. Distinguishes the states that previously all
|
|
1212
|
+
* returned one opaque error, so the agent can tell the tester what to do.
|
|
1213
|
+
*/
|
|
1214
|
+
function liveSessionUnavailableReason() {
|
|
1215
|
+
if (liveSessionActive && isExtensionLinked()) return null;
|
|
1216
|
+
if (relayRoomId) {
|
|
1217
|
+
if (peerAbsentGraceTimer !== null || (relayPeerPresent === false && liveSessionActive)) {
|
|
1218
|
+
return "The tester momentarily disconnected and may be reconnecting. Retry in a few seconds.";
|
|
1219
|
+
}
|
|
1220
|
+
if (!relayPeerPresent) {
|
|
1221
|
+
return "The tester has not joined the session (or has left). Ask them to start their remote session in the TraceGist extension.";
|
|
1222
|
+
}
|
|
1223
|
+
if (!liveSessionActive) {
|
|
1224
|
+
return "The tester is connected but hasn't started recording yet. Ask them to press record; keep polling watch_live_session for the 'session-start' event.";
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return "No active live shadowing session, or the extension is not connected. Ensure the tester is recording with Live Shadowing / a remote session enabled.";
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** End the active relay session and notify polling agents. Idempotent. */
|
|
1231
|
+
function endRelaySession(reason) {
|
|
1232
|
+
clearPeerAbsentGrace();
|
|
1233
|
+
// Only end a RELAY-owned session. A local session that started (e.g. during the
|
|
1234
|
+
// grace window after a relay drop) must not be torn down by relay teardown.
|
|
1235
|
+
if (!liveSessionActive || liveSessionTransport !== "relay") return;
|
|
1236
|
+
liveSessionActive = false;
|
|
1237
|
+
console.error(`[${BRIDGE_NAME}] Relay: session ended (${reason})`);
|
|
1238
|
+
pushLiveEvent("session-ended", { reason });
|
|
1239
|
+
try {
|
|
1240
|
+
server.sendResourceListChanged();
|
|
1241
|
+
} catch {
|
|
1242
|
+
// Not all transports support notifications
|
|
1243
|
+
}
|
|
1152
1244
|
}
|
|
1153
1245
|
|
|
1154
1246
|
function startLiveShadowServer() {
|
|
@@ -1238,6 +1330,9 @@ function startLiveShadowServer() {
|
|
|
1238
1330
|
function handleExtensionMessage(msg, source = "local") {
|
|
1239
1331
|
switch (msg.type) {
|
|
1240
1332
|
case "session-start": {
|
|
1333
|
+
// A session is (re)starting — cancel any pending grace-end so it can't
|
|
1334
|
+
// fire against this fresh session.
|
|
1335
|
+
clearPeerAbsentGrace();
|
|
1241
1336
|
// The extension re-announces the session whenever the relay peer
|
|
1242
1337
|
// (re)connects. A re-announce of the SAME session must not wipe
|
|
1243
1338
|
// buffered events or un-retrieved question responses.
|
|
@@ -1402,6 +1497,8 @@ function handleExtensionMessage(msg, source = "local") {
|
|
|
1402
1497
|
// A session-end from the transport that doesn't own the session must
|
|
1403
1498
|
// not tear down the owning transport's session.
|
|
1404
1499
|
if (liveSessionTransport && source !== liveSessionTransport) break;
|
|
1500
|
+
// Explicit end (recording stopped / consent revoked) — end now, no grace.
|
|
1501
|
+
clearPeerAbsentGrace();
|
|
1405
1502
|
liveSessionActive = false;
|
|
1406
1503
|
console.error(`[${BRIDGE_NAME}] Live shadow: session ended`);
|
|
1407
1504
|
try {
|
|
@@ -1424,8 +1521,16 @@ function handleExtensionMessage(msg, source = "local") {
|
|
|
1424
1521
|
|
|
1425
1522
|
/** @type {WebSocket | null} */
|
|
1426
1523
|
let relayWs = null;
|
|
1427
|
-
/**
|
|
1428
|
-
|
|
1524
|
+
/**
|
|
1525
|
+
* Per-connection ephemeral-key handshake (agent side). Owns the connection key,
|
|
1526
|
+
* send counter, and replay guard. Created on join_live_session.
|
|
1527
|
+
* @type {HandshakeManager | null}
|
|
1528
|
+
*/
|
|
1529
|
+
let relayHandshake = null;
|
|
1530
|
+
/** Cap on messages buffered while the relay handshake is in flight (drop-oldest). */
|
|
1531
|
+
const RELAY_PENDING_SENDS_MAX = 2000;
|
|
1532
|
+
/** Inner messages awaiting the handshake before they can be encrypted + sent. */
|
|
1533
|
+
let relayPendingSends = [];
|
|
1429
1534
|
let relayRoomId = null;
|
|
1430
1535
|
let relayUrl = null;
|
|
1431
1536
|
let relayPeerPresent = false;
|
|
@@ -1459,20 +1564,31 @@ let relaySendChain = Promise.resolve();
|
|
|
1459
1564
|
* @param {object} msg
|
|
1460
1565
|
*/
|
|
1461
1566
|
async function sendToExtension(msg) {
|
|
1462
|
-
const canRelay = isRelayConnected() &&
|
|
1567
|
+
const canRelay = isRelayConnected() && relayHandshake !== null;
|
|
1463
1568
|
const useRelay = canRelay && (liveSessionTransport === "relay" || activeConnection === null);
|
|
1464
1569
|
if (useRelay) {
|
|
1465
1570
|
// Hosted relays cap WebSocket messages (Cloudflare: 1 MiB); oversized
|
|
1466
1571
|
// messages travel as encrypted frame-chunk parts (see live-crypto.mjs).
|
|
1572
|
+
const parts = chunkInnerMessage(msg) ?? [msg];
|
|
1573
|
+
// If the handshake hasn't completed yet, buffer the parts; they are flushed
|
|
1574
|
+
// once the connection key is established. Bounded (drop-oldest) so a relay
|
|
1575
|
+
// that never delivers the tester handshake can't grow this unboundedly —
|
|
1576
|
+
// matches the extension's buffer cap (the agent has no handshake timeout).
|
|
1577
|
+
if (!relayHandshake.ready) {
|
|
1578
|
+
for (const part of parts) {
|
|
1579
|
+
if (relayPendingSends.length >= RELAY_PENDING_SENDS_MAX) relayPendingSends.shift();
|
|
1580
|
+
relayPendingSends.push(part);
|
|
1581
|
+
}
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1467
1584
|
const send = relaySendChain.then(async () => {
|
|
1468
1585
|
// Re-check at execution time: the socket may have closed while an
|
|
1469
1586
|
// earlier message in the chain was encrypting.
|
|
1470
|
-
if (!isRelayConnected() ||
|
|
1587
|
+
if (!isRelayConnected() || relayHandshake === null || !relayHandshake.ready) {
|
|
1471
1588
|
throw new Error("Extension not connected (relay closed while sending)");
|
|
1472
1589
|
}
|
|
1473
|
-
const parts = chunkInnerMessage(msg) ?? [msg];
|
|
1474
1590
|
for (const part of parts) {
|
|
1475
|
-
const payload = await
|
|
1591
|
+
const payload = await relayHandshake.encryptData(part);
|
|
1476
1592
|
relayWs.send(JSON.stringify({ type: "frame", payload }));
|
|
1477
1593
|
}
|
|
1478
1594
|
});
|
|
@@ -1487,8 +1603,26 @@ async function sendToExtension(msg) {
|
|
|
1487
1603
|
throw new Error("Extension not connected (no local or relay transport)");
|
|
1488
1604
|
}
|
|
1489
1605
|
|
|
1606
|
+
/** Encrypt + send inner messages buffered while the relay handshake was in flight. */
|
|
1607
|
+
function flushRelayPendingSends() {
|
|
1608
|
+
const hs = relayHandshake;
|
|
1609
|
+
if (!hs || !hs.ready || relayPendingSends.length === 0) return;
|
|
1610
|
+
const queued = relayPendingSends;
|
|
1611
|
+
relayPendingSends = [];
|
|
1612
|
+
const send = relaySendChain.then(async () => {
|
|
1613
|
+
for (const part of queued) {
|
|
1614
|
+
if (!isRelayConnected() || relayHandshake !== hs || !hs.ready) return;
|
|
1615
|
+
relayWs.send(JSON.stringify({ type: "frame", payload: await hs.encryptData(part) }));
|
|
1616
|
+
}
|
|
1617
|
+
});
|
|
1618
|
+
relaySendChain = send.catch(() => {});
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1490
1621
|
function disconnectRelay() {
|
|
1491
1622
|
relayIntentionalClose = true;
|
|
1623
|
+
// Cancel any pending grace timer — otherwise it can fire after a new session
|
|
1624
|
+
// (e.g. a rejoin with a different code) has taken over and end it spuriously.
|
|
1625
|
+
clearPeerAbsentGrace();
|
|
1492
1626
|
if (relayReconnectTimer) {
|
|
1493
1627
|
clearTimeout(relayReconnectTimer);
|
|
1494
1628
|
relayReconnectTimer = undefined;
|
|
@@ -1501,7 +1635,8 @@ function disconnectRelay() {
|
|
|
1501
1635
|
}
|
|
1502
1636
|
}
|
|
1503
1637
|
relayWs = null;
|
|
1504
|
-
|
|
1638
|
+
relayHandshake = null;
|
|
1639
|
+
relayPendingSends = [];
|
|
1505
1640
|
relayRoomId = null;
|
|
1506
1641
|
relayPeerPresent = false;
|
|
1507
1642
|
relayReconnectAttempts = 0;
|
|
@@ -1546,7 +1681,8 @@ function connectRelay() {
|
|
|
1546
1681
|
// one frame can never reorder events behind a faster later frame (the
|
|
1547
1682
|
// live event buffer assumes ascending seq).
|
|
1548
1683
|
let frameChain = Promise.resolve();
|
|
1549
|
-
// Per-connection: a new socket can never continue a previous reassembly.
|
|
1684
|
+
// Per-connection: a new socket can never continue a previous reassembly. The
|
|
1685
|
+
// handshake manager owns the connection key + counter + replay guard.
|
|
1550
1686
|
const chunkAssembler = new FrameChunkAssembler();
|
|
1551
1687
|
|
|
1552
1688
|
ws.on("message", (raw) => {
|
|
@@ -1570,6 +1706,8 @@ function connectRelay() {
|
|
|
1570
1706
|
|
|
1571
1707
|
case "peer-joined":
|
|
1572
1708
|
relayPeerPresent = true;
|
|
1709
|
+
// Tester is back within the grace window — cancel the pending end.
|
|
1710
|
+
clearPeerAbsentGrace();
|
|
1573
1711
|
pushLiveEvent("tester-connected", {
|
|
1574
1712
|
hint: "The tester's extension joined the remote session.",
|
|
1575
1713
|
});
|
|
@@ -1577,17 +1715,28 @@ function connectRelay() {
|
|
|
1577
1715
|
|
|
1578
1716
|
case "peer-left":
|
|
1579
1717
|
relayPeerPresent = false;
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1718
|
+
// Do NOT end the session immediately: the tester's extension retries
|
|
1719
|
+
// for ~30s and re-announces on reconnect, so a transient drop (service
|
|
1720
|
+
// worker suspend, network blip) should not kill the session and lose an
|
|
1721
|
+
// in-flight reply. Start a grace timer; an explicit `session-end` frame
|
|
1722
|
+
// still ends the session at once via handleExtensionMessage.
|
|
1723
|
+
if (liveSessionActive && peerAbsentGraceTimer === null) {
|
|
1724
|
+
if (PEER_ABSENT_GRACE_MS === 0) {
|
|
1725
|
+
endRelaySession("tester disconnected");
|
|
1726
|
+
} else {
|
|
1727
|
+
peerAbsentGraceTimer = setTimeout(() => {
|
|
1728
|
+
peerAbsentGraceTimer = null;
|
|
1729
|
+
if (!relayPeerPresent) endRelaySession("tester did not reconnect");
|
|
1730
|
+
}, PEER_ABSENT_GRACE_MS);
|
|
1731
|
+
if (typeof peerAbsentGraceTimer.unref === "function") peerAbsentGraceTimer.unref();
|
|
1587
1732
|
}
|
|
1588
1733
|
}
|
|
1589
1734
|
pushLiveEvent("tester-disconnected", {
|
|
1590
|
-
|
|
1735
|
+
reconnectGraceMs: PEER_ABSENT_GRACE_MS,
|
|
1736
|
+
hint:
|
|
1737
|
+
PEER_ABSENT_GRACE_MS > 0
|
|
1738
|
+
? `The tester's extension dropped. Waiting up to ${Math.round(PEER_ABSENT_GRACE_MS / 1000)}s for it to reconnect before ending the session — keep polling.`
|
|
1739
|
+
: "The tester's extension left the remote session.",
|
|
1591
1740
|
});
|
|
1592
1741
|
break;
|
|
1593
1742
|
|
|
@@ -1595,9 +1744,23 @@ function connectRelay() {
|
|
|
1595
1744
|
const payload = msg.payload;
|
|
1596
1745
|
frameChain = frameChain
|
|
1597
1746
|
.then(async () => {
|
|
1598
|
-
const
|
|
1747
|
+
const hs = relayHandshake;
|
|
1748
|
+
if (!hs || relayWs !== ws) return;
|
|
1749
|
+
if (isHandshakeFrame(payload)) {
|
|
1750
|
+
// The tester initiated (or retransmitted) a handshake. Reply with
|
|
1751
|
+
// our nonce and, once the key is ready, flush any buffered sends.
|
|
1752
|
+
const result = await hs.onHandshakeFrame(payload);
|
|
1753
|
+
if (result.kind === "response") {
|
|
1754
|
+
relayWs.send(JSON.stringify({ type: "frame", payload: result.payload }));
|
|
1755
|
+
if (result.keyReady) flushRelayPendingSends();
|
|
1756
|
+
}
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
// Data frame from the tester: decrypt + replay-check under the
|
|
1760
|
+
// connection key (rejects reflected/replayed/previous-connection).
|
|
1761
|
+
const inner = await hs.decryptData(payload);
|
|
1599
1762
|
if (inner === null) {
|
|
1600
|
-
console.error(`[${BRIDGE_NAME}] Relay: dropped undecryptable frame`);
|
|
1763
|
+
console.error(`[${BRIDGE_NAME}] Relay: dropped undecryptable/replayed frame`);
|
|
1601
1764
|
return;
|
|
1602
1765
|
}
|
|
1603
1766
|
// Chunk parts return null until the final part completes.
|
|
@@ -1648,6 +1811,7 @@ function scheduleRelayReconnect() {
|
|
|
1648
1811
|
// The relay-owned session is unreachable for good — end it so polling
|
|
1649
1812
|
// agents see sessionActive:false instead of a permanently dead session.
|
|
1650
1813
|
if (liveSessionActive && liveSessionTransport === "relay") {
|
|
1814
|
+
clearPeerAbsentGrace();
|
|
1651
1815
|
liveSessionActive = false;
|
|
1652
1816
|
pushLiveEvent("relay-connection-lost", {
|
|
1653
1817
|
hint: "Relay connection lost and reconnection failed; the remote session has ended. Use join_live_session to reconnect.",
|
|
@@ -1709,8 +1873,10 @@ server.registerTool(
|
|
|
1709
1873
|
description:
|
|
1710
1874
|
"Watch a live TraceGist shadowing session in real-time. Returns buffered events since the given sequence number.\n\n" +
|
|
1711
1875
|
"IMPORTANT POLLING BEHAVIOR:\n" +
|
|
1712
|
-
"-
|
|
1713
|
-
"
|
|
1876
|
+
"- PREFER long-polling: pass `wait_seconds` (e.g. 25) with `since_seq = nextSeq`. The call blocks until a\n" +
|
|
1877
|
+
" new event arrives or the session ends, so you react instantly without a fixed sleep-and-poll loop.\n" +
|
|
1878
|
+
"- Keep calling in a loop until sessionActive becomes false; with wait_seconds you can loop back immediately.\n" +
|
|
1879
|
+
"- Do NOT stop just because a call returned no new events — the tester is still active.\n" +
|
|
1714
1880
|
"- When you see 'voice-transcription' events, the tester recorded a voice marker — read the transcription and react to it.\n" +
|
|
1715
1881
|
"- When you see 'question-response-received' events, use get_tester_response to retrieve the full answer with images.\n" +
|
|
1716
1882
|
"- When you see 'question-response-transcribed' events, the tester's voice response has been transcribed — read it for context.\n" +
|
|
@@ -1742,9 +1908,47 @@ server.registerTool(
|
|
|
1742
1908
|
`Maximum events to return per call (default: ${WATCH_LIVE_DEFAULT_LIMIT}, max: ${WATCH_LIVE_MAX_LIMIT}). ` +
|
|
1743
1909
|
"Use the returned `nextSeq` as `since_seq` on the next call when `hasMore` is true.",
|
|
1744
1910
|
),
|
|
1911
|
+
wait_seconds: z
|
|
1912
|
+
.number()
|
|
1913
|
+
.min(0)
|
|
1914
|
+
.max(WATCH_LIVE_MAX_WAIT_SECONDS)
|
|
1915
|
+
.optional()
|
|
1916
|
+
.describe(
|
|
1917
|
+
`Long-poll: block up to this many seconds (max ${WATCH_LIVE_MAX_WAIT_SECONDS}) waiting for a new ` +
|
|
1918
|
+
"event after `since_seq`, returning immediately when one arrives (or the session ends). " +
|
|
1919
|
+
"STRONGLY PREFERRED over fixed-interval polling — pass e.g. 25 with `since_seq = nextSeq` to " +
|
|
1920
|
+
"react the instant the tester acts, instead of sleeping and re-polling. Omit or 0 to return at once.",
|
|
1921
|
+
),
|
|
1745
1922
|
}),
|
|
1746
1923
|
},
|
|
1747
|
-
async ({ since_seq, limit = WATCH_LIVE_DEFAULT_LIMIT }) => {
|
|
1924
|
+
async ({ since_seq, limit = WATCH_LIVE_DEFAULT_LIMIT, wait_seconds = 0 }) => {
|
|
1925
|
+
// Long-poll: block until there's a new event after since_seq, the session
|
|
1926
|
+
// ends, or the wait budget elapses. Only waits while a session is live or a
|
|
1927
|
+
// relay room is joined — otherwise falls straight through to the error below.
|
|
1928
|
+
const waitMs = Math.min(Math.max(wait_seconds, 0), WATCH_LIVE_MAX_WAIT_SECONDS) * 1000;
|
|
1929
|
+
if (waitMs > 0) {
|
|
1930
|
+
const deadline = Date.now() + waitMs;
|
|
1931
|
+
while ((liveSessionActive || relayRoomId) && Date.now() < deadline) {
|
|
1932
|
+
const hasNew =
|
|
1933
|
+
since_seq != null ? liveEvents.some((e) => e.seq > since_seq) : liveEvents.length > 0;
|
|
1934
|
+
if (hasNew) break;
|
|
1935
|
+
// Wait for the next pushLiveEvent, capped at 1s slices so we re-check
|
|
1936
|
+
// session/deadline state (e.g. the session ending) even without an event.
|
|
1937
|
+
await new Promise((resolve) => {
|
|
1938
|
+
let settled = false;
|
|
1939
|
+
const wake = () => {
|
|
1940
|
+
if (settled) return;
|
|
1941
|
+
settled = true;
|
|
1942
|
+
clearTimeout(timer);
|
|
1943
|
+
const i = eventWaiters.indexOf(wake);
|
|
1944
|
+
if (i >= 0) eventWaiters.splice(i, 1);
|
|
1945
|
+
resolve();
|
|
1946
|
+
};
|
|
1947
|
+
const timer = setTimeout(wake, Math.min(deadline - Date.now(), 1000));
|
|
1948
|
+
eventWaiters.push(wake);
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1748
1952
|
if (!liveSessionActive && liveEvents.length === 0) {
|
|
1749
1953
|
// Joined a remote room but the tester hasn't started yet — this is the
|
|
1750
1954
|
// documented flow after join_live_session, not an error.
|
|
@@ -1822,14 +2026,15 @@ server.registerTool(
|
|
|
1822
2026
|
|
|
1823
2027
|
if (liveSessionActive) {
|
|
1824
2028
|
responseData.pollingGuidance =
|
|
1825
|
-
"KEEP
|
|
2029
|
+
"KEEP WATCHING — the tester is still active. Prefer calling again with wait_seconds (e.g. 25) and " +
|
|
2030
|
+
"since_seq = nextSeq to block until the next event instead of fixed-interval polling. " +
|
|
1826
2031
|
"Watch for 'voice-transcription' events (tester voice markers) and 'question-response-received' events. " +
|
|
1827
2032
|
"React to voice markers by acknowledging them or asking follow-up questions.";
|
|
1828
2033
|
responseData.availableActions = [
|
|
1829
2034
|
{
|
|
1830
2035
|
tool: "ask_tester_question",
|
|
1831
2036
|
description:
|
|
1832
|
-
"
|
|
2037
|
+
"Send the tester a question or short message (max 500 chars). They respond with voice + highlights.",
|
|
1833
2038
|
},
|
|
1834
2039
|
{
|
|
1835
2040
|
tool: "get_live_screenshot",
|
|
@@ -1900,17 +2105,17 @@ server.registerTool(
|
|
|
1900
2105
|
inputSchema: z.object({
|
|
1901
2106
|
question: z
|
|
1902
2107
|
.string()
|
|
1903
|
-
.max(
|
|
2108
|
+
.max(500)
|
|
1904
2109
|
.describe(
|
|
1905
|
-
"
|
|
1906
|
-
"
|
|
2110
|
+
"Message for the tester — a question OR a statement/reply (max 500 chars). " +
|
|
2111
|
+
"Examples: 'Can you click Save again?' · 'Thanks — I see the 500 now, fixing it.' · " +
|
|
2112
|
+
"'In my opinion the layout bug is the CSS grid gap.'",
|
|
1907
2113
|
),
|
|
1908
2114
|
}),
|
|
1909
2115
|
},
|
|
1910
2116
|
async ({ question }) => {
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
}
|
|
2117
|
+
const unavailable = liveSessionUnavailableReason();
|
|
2118
|
+
if (unavailable) return toolError(unavailable);
|
|
1914
2119
|
|
|
1915
2120
|
const questionId = crypto.randomUUID();
|
|
1916
2121
|
try {
|
|
@@ -2113,9 +2318,8 @@ server.registerTool(
|
|
|
2113
2318
|
inputSchema: z.object({}),
|
|
2114
2319
|
},
|
|
2115
2320
|
async () => {
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
}
|
|
2321
|
+
const unavailable = liveSessionUnavailableReason();
|
|
2322
|
+
if (unavailable) return toolError(unavailable);
|
|
2119
2323
|
|
|
2120
2324
|
// Request screenshot from extension and wait for response
|
|
2121
2325
|
const screenshotPromise = new Promise((resolve, reject) => {
|
|
@@ -2204,15 +2408,24 @@ server.registerTool(
|
|
|
2204
2408
|
);
|
|
2205
2409
|
}
|
|
2206
2410
|
|
|
2207
|
-
// Replace any previous remote session
|
|
2411
|
+
// Replace any previous remote session. disconnectRelay() clears the grace
|
|
2412
|
+
// timer; also reset the owning session state (as leave_live_session does) so
|
|
2413
|
+
// a still-"active" session held open by the grace window doesn't carry its
|
|
2414
|
+
// stale sessionId/meta into the new room until a session-start corrects it.
|
|
2208
2415
|
if (relayWs || relayRoomId) {
|
|
2209
2416
|
disconnectRelay();
|
|
2417
|
+
if (liveSessionActive && liveSessionTransport === "relay") {
|
|
2418
|
+
liveSessionActive = false;
|
|
2419
|
+
liveSessionTransport = null;
|
|
2420
|
+
liveSessionMeta = null;
|
|
2421
|
+
}
|
|
2210
2422
|
}
|
|
2211
2423
|
|
|
2212
2424
|
relayIntentionalClose = false;
|
|
2213
2425
|
relayUrl = process.env.TRACEGIST_RELAY_URL || DEFAULT_RELAY_URL;
|
|
2214
2426
|
relayRoomId = await deriveRoomId(normalized);
|
|
2215
|
-
|
|
2427
|
+
relayPendingSends = [];
|
|
2428
|
+
relayHandshake = await HandshakeManager.create("agent", normalized);
|
|
2216
2429
|
|
|
2217
2430
|
try {
|
|
2218
2431
|
const { peerPresent } = await connectRelay();
|