leetcode-capture-helper 0.0.0-stage → 0.1.1

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.
Files changed (48) hide show
  1. package/cli.js +92 -0
  2. package/config.js +25 -0
  3. package/framing.js +57 -0
  4. package/host.js +420 -0
  5. package/install.js +290 -0
  6. package/package.json +31 -4
  7. package/runtime/bundle.json +42 -0
  8. package/runtime/companion/capture-log.js +83 -0
  9. package/runtime/companion/codex-backend.js +337 -0
  10. package/runtime/companion/companion.js +2190 -0
  11. package/runtime/companion/mouse-input.js +154 -0
  12. package/runtime/companion/package.json +4 -0
  13. package/runtime/companion/panel-bridge.js +273 -0
  14. package/runtime/companion/settings-rpc.js +228 -0
  15. package/runtime/companion/settings.js +464 -0
  16. package/runtime/companion/terminal-format.js +330 -0
  17. package/runtime/companion/vault-summary.js +168 -0
  18. package/runtime/companion/virtual-terminal.js +138 -0
  19. package/runtime/extension/shared/anthropic-backend.js +193 -0
  20. package/runtime/extension/shared/catalog.js +415 -0
  21. package/runtime/extension/shared/code-ledger.js +333 -0
  22. package/runtime/extension/shared/context-budget.js +259 -0
  23. package/runtime/extension/shared/context-summary.js +271 -0
  24. package/runtime/extension/shared/conversation.js +81 -0
  25. package/runtime/extension/shared/event-hub.js +126 -0
  26. package/runtime/extension/shared/extension-settings.js +445 -0
  27. package/runtime/extension/shared/helper-launcher.js +248 -0
  28. package/runtime/extension/shared/local-backend.js +706 -0
  29. package/runtime/extension/shared/local-server.js +149 -0
  30. package/runtime/extension/shared/package.json +4 -0
  31. package/runtime/extension/shared/practice.js +228 -0
  32. package/runtime/extension/shared/problem-index.js +4128 -0
  33. package/runtime/extension/shared/profiles.js +106 -0
  34. package/runtime/extension/shared/providers.js +58 -0
  35. package/runtime/extension/shared/reply-guard.js +864 -0
  36. package/runtime/extension/shared/roadmap.js +1337 -0
  37. package/runtime/extension/shared/sanitize.js +68 -0
  38. package/runtime/extension/shared/solved.js +316 -0
  39. package/runtime/extension/shared/tutor-engine.js +369 -0
  40. package/runtime/extension/shared/tutor-prompt.js +108 -0
  41. package/runtime/extension/shared/tutor-service.js +520 -0
  42. package/runtime/relay-server/package.json +4 -0
  43. package/runtime/relay-server/panel-broker.js +797 -0
  44. package/runtime/relay-server/server.js +241 -0
  45. package/runtime/vault-tool/log-session.js +230 -0
  46. package/runtime/vault-tool/package.json +4 -0
  47. package/runtime/vault-tool/vault-notes.js +206 -0
  48. package/README.md +0 -3
@@ -0,0 +1,797 @@
1
+ // LeetCode Capture - in-page tutor panel broker
2
+ //
3
+ // The companion (a separate local process) publishes tutor events here, and
4
+ // the in-page panel (the extension's panel.js, running on leetcode.com)
5
+ // streams them out over Server-Sent Events and posts typed messages back.
6
+ // This module only brokers: it never calls an LLM, and it keeps everything in
7
+ // memory (a bounded event buffer), so a relay restart simply starts the panel
8
+ // transcript fresh. Sequence numbers restart at 1 with it, so every event
9
+ // (and the status event) carries this run's random `epoch`: a panel that
10
+ // reconnects with `?after=<seq>&epoch=<old>` gets the whole new buffer
11
+ // instead of having it filtered against a counter from a previous run, and
12
+ // it resets its own `lastSeq` when it sees a new epoch.
13
+ //
14
+ // Routes (all under /panel/):
15
+ // GET /panel/events?after=<seq> panel SSE stream of tutor events (replays the buffer after <seq>)
16
+ // POST /panel/message panel a typed message for the tutor
17
+ // POST /panel/switch-profile panel { id }: the model switcher picked a saved model
18
+ // POST /panel/publish companion one event, or { events: [...] }
19
+ // GET /panel/inbox companion SSE stream of the panel's typed messages
20
+ // POST /panel/drop-problem companion { problemSlug }: forget that problem's buffered events
21
+ // POST /panel/trim-captures companion { before, path }: drop the capture log's first `before` bytes
22
+ // POST /panel/activity companion { busy }: the tutor is working on (or has queued) a turn
23
+ // POST /panel/rpc-result companion { id, response }: the answer to a settings call
24
+ // POST /settings/rpc options { method, params }: a settings call for the companion
25
+ //
26
+ // A `config` event (which provider and model the tutor uses, never a key) is
27
+ // not buffered with the conversation: the newest one is kept on its own and
28
+ // sent to every panel that connects, after the replay and before `status`.
29
+ //
30
+ // POST /panel/switch-profile is the one settings call a panel (and so any
31
+ // script on leetcode.com) can make: it goes to the companion as the rpc
32
+ // `switchProfile` with nothing but the id, and the companion accepts only an
33
+ // id it already has saved, which changes nothing but which saved model is in
34
+ // use (see companion/settings-rpc.js). The answer carries only whether it
35
+ // worked, a reason and the non-secret summary of the model now in use.
36
+ //
37
+ // /settings/rpc is for the extension's own options page (extension/settings.js)
38
+ // only: its Origin must be an extension origin (chrome-extension://<32 letters
39
+ // a-p> or moz-extension://<uuid>), which a web page cannot send, plus the same
40
+ // Host check. The call goes down the companion's inbox stream as
41
+ // `{ type: "rpc", id, method, params }` and the request waits (up to
42
+ // RPC_TIMEOUT_MS) for the companion's POST /panel/rpc-result. Settings carry
43
+ // API keys, so leetcode.com (whose page scripts are not ours) is refused here
44
+ // too. 503 when no companion is connected.
45
+ //
46
+ // Unlike /capture (which only ingests data, so a permissive CORS `*` is
47
+ // harmless), these routes can READ tutor replies, which contain the student's
48
+ // code, so any web page that could reach localhost must not be able to. Two
49
+ // checks run on every request:
50
+ // - Host must be exactly the relay's own loopback address, which defeats DNS
51
+ // rebinding (an attacker's hostname resolving to 127.0.0.1 arrives with
52
+ // the attacker's Host header).
53
+ // - Origin must be absent or exactly https://leetcode.com. Browsers always
54
+ // send Origin on a CORS request and on any POST, and a page cannot forge
55
+ // it, so every other website is rejected.
56
+ // The companion-only routes (publish, inbox, drop-problem, trim-captures, activity, rpc-result) additionally go through
57
+ // companionOnlyRejection below. A missing Origin alone is NOT proof of the
58
+ // companion: a page's `fetch(url, { mode: "no-cors" })` GET carries no Origin
59
+ // either. What a browser page cannot do is add a custom header without a CORS
60
+ // preflight, and that preflight carries an Origin and is refused (and even
61
+ // leetcode.com's preflight is never told this header is allowed). So these
62
+ // routes require COMPANION_HEADER, no Origin, and no cross-site Sec-Fetch-Site.
63
+ // A script on leetcode.com can therefore only read events and post messages.
64
+ //
65
+ // Text in a typed message ends up printed in the student's terminal by the
66
+ // companion, so C0/C1 control characters (escape sequences that could recolor
67
+ // the terminal, retitle the window, or worse) and invisible bidi/zero-width
68
+ // characters (which could disguise what the text says) are stripped at this
69
+ // boundary.
70
+ // The companion strips them again before printing anything untrusted.
71
+ //
72
+ // The number of open panel streams is capped, and only CORS-mode requests
73
+ // count: a page's no-cors GET (or an <img> or <iframe>) to /panel/events has
74
+ // no Origin, so without the Sec-Fetch-Mode check in handle() it could hold the
75
+ // slots open, unread, and lock the real panel out.
76
+
77
+ "use strict";
78
+
79
+ const crypto = require("crypto");
80
+ const fs = require("fs");
81
+ const path = require("path");
82
+
83
+ const PANEL_ORIGIN = "https://leetcode.com";
84
+ // Sent by companion/panel-bridge.js on every companion-only request.
85
+ const COMPANION_HEADER = "x-lc-companion";
86
+
87
+ const MAX_BUFFERED_EVENTS = 2000;
88
+ // A message may carry the live editor code (its `snapshot`), so the body cap
89
+ // is sized for a long solution, not for the typed text alone. Real solutions
90
+ // are a few KB; the snapshot's code and description caps (plus the typed
91
+ // text) fit well under the body cap as plain JSON. extension/panel.js keeps
92
+ // the same three numbers and leaves the snapshot out of a message rather than
93
+ // send a body past them (never a truncated copy of the code).
94
+ const MAX_MESSAGE_BYTES = 256 * 1024;
95
+ const MAX_SNAPSHOT_CODE_CHARS = 100_000;
96
+ const MAX_SNAPSHOT_DESCRIPTION_CHARS = 50_000;
97
+ const MAX_PUBLISH_BYTES = 5 * 1024 * 1024;
98
+ const MAX_TEXT_CHARS = 200_000;
99
+ const MAX_MESSAGE_CHARS = 8000;
100
+ const MAX_PANEL_STREAMS = 20;
101
+ const MAX_OPEN_TURNS = 100;
102
+ const HEARTBEAT_MS = 15_000;
103
+
104
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,199}$/i;
105
+
106
+ // The options page's origin: Chrome's extension ids are 32 letters a-p,
107
+ // Firefox's are a random UUID per install.
108
+ const EXTENSION_ORIGIN_RE =
109
+ /^(chrome-extension:\/\/[a-p]{32}|moz-extension:\/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
110
+ // A connection test can wait on a slow model (settings-rpc.js gives it 90 s).
111
+ const RPC_TIMEOUT_MS = 120_000;
112
+ // A model switch answers only once the new model is in place, after the reply
113
+ // already running (companion/settings-rpc.js), which may take minutes. The
114
+ // panel waits 180 s (SWITCH_TIMEOUT_MS in extension/panel.js), so the relay
115
+ // waits longer and the panel is always the one that gives up; a switch that
116
+ // lands after that still reaches the panel as the companion's next config
117
+ // event, which names the active profile.
118
+ const SWITCH_RPC_TIMEOUT_MS = 190_000;
119
+ const MAX_RPC_BYTES = 64 * 1024;
120
+ const MAX_RPC_RESULT_BYTES = 1024 * 1024;
121
+ const MAX_PENDING_RPCS = 50;
122
+ const RPC_METHOD_RE = /^[a-z][a-zA-Z]{0,39}$/;
123
+ // Same as extension/shared/profiles.js's PROFILE_ID_RE.
124
+ const PROFILE_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
125
+ // A switch waits for the reply already running, so it may take a while.
126
+ const SWITCH_STATUS = { unknown: 404, locked: 409, unavailable: 409 };
127
+
128
+ // Everything in C0 except tab and newline, plus DEL and all of C1 (which some
129
+ // terminals honor as 8-bit CSI/OSC introducers), plus the invisible bidi and
130
+ // zero-width formatting characters that make text read differently from what
131
+ // it is. The line and paragraph separators become a newline. The zero-width
132
+ // joiner and non-joiner stay (emoji sequences and several scripts need them).
133
+ // Same rule as extension/shared/sanitize.js, which explains each range.
134
+ const CONTROL_CHARS_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
135
+ const LINE_SEPARATORS_RE = /[\u2028\u2029]/g;
136
+
137
+ function stripControlChars(text) {
138
+ return text.replace(LINE_SEPARATORS_RE, "\n").replace(CONTROL_CHARS_RE, "");
139
+ }
140
+
141
+ // Returns null when the request plausibly comes from the local companion
142
+ // process, or a reason string for the 403. Shared by every companion-only
143
+ // route; add new ones to COMPANION_ONLY_ROUTES rather than re-deriving this.
144
+ function companionOnlyRejection(req) {
145
+ if (req.headers.origin !== undefined) return "this route is for the companion only";
146
+ const site = req.headers["sec-fetch-site"];
147
+ if (site !== undefined && site !== "none") return "this route is for the companion only";
148
+ if (req.headers[COMPANION_HEADER] !== "1") return `this route is for the companion only (missing ${COMPANION_HEADER})`;
149
+ return null;
150
+ }
151
+
152
+ const COMPANION_ONLY_ROUTES = new Set([
153
+ "POST /panel/publish",
154
+ "GET /panel/inbox",
155
+ "POST /panel/drop-problem",
156
+ "POST /panel/trim-captures",
157
+ "POST /panel/activity",
158
+ "POST /panel/rpc-result",
159
+ ]);
160
+
161
+ const EVENT_TYPES = new Set(["user", "capture", "turn-start", "chunk", "turn-end", "notice"]);
162
+
163
+ function isShortString(value, max) {
164
+ return typeof value === "string" && value.length <= max;
165
+ }
166
+
167
+ // Copies only the fields the panel knows how to render, so a malformed or
168
+ // oversized publish can never smuggle arbitrary data into the buffer.
169
+ function sanitizeEvent(raw) {
170
+ if (!raw || typeof raw !== "object" || !EVENT_TYPES.has(raw.type)) return null;
171
+ const event = { type: raw.type };
172
+ if (raw.problemSlug !== undefined && raw.problemSlug !== null) {
173
+ if (!isShortString(raw.problemSlug, 200)) return null;
174
+ event.problemSlug = raw.problemSlug;
175
+ } else {
176
+ event.problemSlug = null;
177
+ }
178
+ if (raw.turnId !== undefined) {
179
+ if (!isShortString(raw.turnId, 64)) return null;
180
+ event.turnId = raw.turnId;
181
+ }
182
+ if (raw.text !== undefined) {
183
+ if (!isShortString(raw.text, MAX_TEXT_CHARS)) return null;
184
+ event.text = raw.text;
185
+ }
186
+ if (raw.error !== undefined) {
187
+ if (!isShortString(raw.error, 2000)) return null;
188
+ event.error = raw.error;
189
+ }
190
+ if (raw.trigger !== undefined) {
191
+ if (raw.trigger !== "run" && raw.trigger !== "submit") return null;
192
+ event.trigger = raw.trigger;
193
+ }
194
+ if (raw.problemTitle !== undefined && raw.problemTitle !== null) {
195
+ if (!isShortString(raw.problemTitle, 300)) return null;
196
+ event.problemTitle = raw.problemTitle;
197
+ }
198
+ if (raw.attemptSeq !== undefined && raw.attemptSeq !== null) {
199
+ if (!Number.isInteger(raw.attemptSeq) || raw.attemptSeq < 0) return null;
200
+ event.attemptSeq = raw.attemptSeq;
201
+ }
202
+ // The notice of a reset on a problem switch (the panel treats it as the
203
+ // start of a fresh session).
204
+ if (raw.type === "notice" && raw.switch === true) event.switch = true;
205
+ return event;
206
+ }
207
+
208
+ // The non-secret summary of the tutor's provider, as settings.js's
209
+ // statusSummary makes it. Anything else is dropped.
210
+ function sanitizeConfig(raw) {
211
+ if (!raw || typeof raw !== "object") return null;
212
+ if (typeof raw.provider !== "string" || !/^[a-z]{1,40}$/.test(raw.provider)) return null;
213
+ if (!isShortString(raw.label, 100)) return null;
214
+ const config = { type: "config", provider: raw.provider, label: stripControlChars(raw.label) };
215
+ if (raw.model === null || raw.model === undefined) config.model = null;
216
+ else if (isShortString(raw.model, 200)) config.model = stripControlChars(raw.model);
217
+ else return null;
218
+ // The saved profile in use (an id only), left out when none is.
219
+ if (typeof raw.profile === "string" && PROFILE_ID_RE.test(raw.profile)) config.profile = raw.profile;
220
+ else if (raw.profile !== null && raw.profile !== undefined) return null;
221
+ return config;
222
+ }
223
+
224
+ // The live editor code a panel message may carry (read by the extension at
225
+ // send time), reduced to the fields the companion uses. Anything malformed
226
+ // is dropped and the message goes without it: the companion then falls back
227
+ // to the newest recorded Run or Submit. The code itself is passed on as is;
228
+ // the companion never prints it.
229
+ function sanitizeSnapshot(raw) {
230
+ if (!raw || typeof raw !== "object" || !isShortString(raw.code, MAX_SNAPSHOT_CODE_CHARS)) return null;
231
+ const snapshot = { code: raw.code };
232
+ if (isShortString(raw.language, 50)) snapshot.language = stripControlChars(raw.language);
233
+ if (isShortString(raw.problemTitle, 300)) snapshot.problemTitle = stripControlChars(raw.problemTitle);
234
+ if (isShortString(raw.problemDescription, MAX_SNAPSHOT_DESCRIPTION_CHARS)) {
235
+ snapshot.problemDescription = stripControlChars(raw.problemDescription);
236
+ }
237
+ return snapshot;
238
+ }
239
+
240
+ // Whether two paths name the same file: the companion tails the file it was
241
+ // configured with, and a trim must never cut a different one (with only
242
+ // LEETCODE_CAPTURES_FILE or only CAPTURE_LOG_PATH set, they differ).
243
+ function sameFile(a, b) {
244
+ const real = (p) => {
245
+ try {
246
+ return fs.realpathSync(p);
247
+ } catch {
248
+ return path.resolve(p);
249
+ }
250
+ };
251
+ return real(a) === real(b);
252
+ }
253
+
254
+ // captureLog, when given, is { path, trim(before) } from server.js; without
255
+ // it the trim route answers 404.
256
+ function createPanelBroker({ port, captureLog = null, rpcTimeoutMs = RPC_TIMEOUT_MS, switchTimeoutMs = SWITCH_RPC_TIMEOUT_MS }) {
257
+ const allowedHosts = new Set([`localhost:${port}`, `127.0.0.1:${port}`]);
258
+
259
+ // Identifies this run of the relay; see the file header.
260
+ const epoch = crypto.randomBytes(8).toString("hex");
261
+ let nextSeq = 1;
262
+ let events = []; // bounded, oldest first
263
+ const panelClients = new Set(); // SSE responses for panels
264
+ const consumers = new Set(); // SSE responses for companions (inbox)
265
+ let nextMessageId = 1;
266
+ // Replies that have started but not ended, with the text streamed so far:
267
+ // turnId -> { problemSlug, text, closed }. The companion ends every turn it
268
+ // finishes, but a companion that exits mid-reply (Ctrl+C, a crash, the
269
+ // terminal closed) never sends that end, and the panel would show the
270
+ // reply as still typing forever, in every tab opened later too. When the
271
+ // last companion's inbox stream goes away (and after any publish that
272
+ // arrives while none is connected), closeOpenTurns ends each of them as
273
+ // interrupted. `closed` marks one ended that way, so a companion that only
274
+ // lost its stream and keeps streaming the same turn reopens it.
275
+ const openTurns = new Map();
276
+ // Whether the companion is working on a turn or has one queued (it says so
277
+ // on POST /panel/activity). Shown in the panel's header as "Processing";
278
+ // cleared when the last companion goes away, since nothing will clear it
279
+ // then.
280
+ let companionBusy = false;
281
+ // The newest config event from the companion (see the file header).
282
+ let latestConfig = null;
283
+ // Settings calls waiting for the companion's answer: id -> { req, res, timer }.
284
+ const pendingRpcs = new Map();
285
+
286
+ function corsHeaders(req) {
287
+ // Only ever echoed for the one allowed origin; a request with no Origin
288
+ // header (the companion) needs no CORS headers at all.
289
+ if (req.headers.origin !== PANEL_ORIGIN) return {};
290
+ return {
291
+ "Access-Control-Allow-Origin": PANEL_ORIGIN,
292
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
293
+ "Access-Control-Allow-Headers": "Content-Type, Last-Event-ID",
294
+ // Chrome's Private Network Access preflight asks for this when a public
295
+ // https page talks to a loopback address.
296
+ "Access-Control-Allow-Private-Network": "true",
297
+ "Access-Control-Max-Age": "600",
298
+ Vary: "Origin",
299
+ };
300
+ }
301
+
302
+ // The options page's fetch: Chrome and Firefox skip CORS for an extension
303
+ // page with host permission for the relay, but answering the preflight and
304
+ // echoing the origin keeps it working either way.
305
+ function settingsCorsHeaders(req) {
306
+ const origin = req.headers.origin;
307
+ if (typeof origin !== "string" || !EXTENSION_ORIGIN_RE.test(origin)) return {};
308
+ return {
309
+ "Access-Control-Allow-Origin": origin,
310
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
311
+ "Access-Control-Allow-Headers": "Content-Type",
312
+ "Access-Control-Max-Age": "600",
313
+ Vary: "Origin",
314
+ };
315
+ }
316
+
317
+ function sendJson(req, res, status, body) {
318
+ if (res.headersSent || res.writableEnded) return;
319
+ const cors = req.url.startsWith("/settings/") ? settingsCorsHeaders(req) : corsHeaders(req);
320
+ res.writeHead(status, { "Content-Type": "application/json", ...cors });
321
+ res.end(JSON.stringify(body));
322
+ }
323
+
324
+ function sseWrite(res, { id, data }) {
325
+ if (id !== undefined) res.write(`id: ${id}\n`);
326
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
327
+ }
328
+
329
+ // openTurns lists the replies this relay has seen start and not yet end
330
+ // (or ended as interrupted). A panel that lost its stream marks every
331
+ // reply it was showing as interrupted, since a relay that died with its
332
+ // companion never sends their end; after a reconnect to the same run, the
333
+ // ones listed here are alive after all.
334
+ function statusEvent() {
335
+ const open = [];
336
+ for (const [turnId, turn] of openTurns) if (!turn.closed) open.push(turnId);
337
+ return { type: "status", companionOnline: consumers.size > 0, busy: consumers.size > 0 && companionBusy, epoch, openTurns: open };
338
+ }
339
+
340
+ function broadcastStatus() {
341
+ for (const res of panelClients) sseWrite(res, { data: statusEvent() });
342
+ }
343
+
344
+ function trackTurn(event) {
345
+ if (!event.turnId) return;
346
+ if (event.type === "turn-start") {
347
+ openTurns.delete(event.turnId);
348
+ openTurns.set(event.turnId, { problemSlug: event.problemSlug, text: "", closed: false });
349
+ // A companion that never ends its turns must not grow this forever.
350
+ if (openTurns.size > MAX_OPEN_TURNS) openTurns.delete(openTurns.keys().next().value);
351
+ } else if (event.type === "chunk") {
352
+ const turn = openTurns.get(event.turnId);
353
+ if (!turn) return;
354
+ turn.closed = false;
355
+ if (event.text && turn.text.length < MAX_TEXT_CHARS) turn.text = (turn.text + event.text).slice(0, MAX_TEXT_CHARS);
356
+ } else if (event.type === "turn-end") {
357
+ openTurns.delete(event.turnId);
358
+ }
359
+ }
360
+
361
+ // Ends every reply still streaming as interrupted, with the text it got
362
+ // so far (a turn-end replaces its chunks in the buffer, see addEvent). The
363
+ // panel shows the same "did not finish" note it uses for a relay restart.
364
+ function closeOpenTurns() {
365
+ for (const [turnId, turn] of openTurns) {
366
+ if (turn.closed) continue;
367
+ turn.closed = true;
368
+ const stored = { type: "turn-end", turnId, problemSlug: turn.problemSlug, text: turn.text, interrupted: true };
369
+ addEvent(stored, { track: false });
370
+ }
371
+ }
372
+
373
+ function addEvent(event, { track = true } = {}) {
374
+ if (track) trackTurn(event);
375
+ const stored = { ...event, seq: nextSeq++, epoch };
376
+ // A turn-end carries the turn's full authoritative text, so the chunks
377
+ // that led up to it are dead weight for anyone replaying the buffer.
378
+ if (stored.type === "turn-end" && stored.turnId) {
379
+ events = events.filter((e) => !(e.type === "chunk" && e.turnId === stored.turnId));
380
+ }
381
+ events.push(stored);
382
+ if (events.length > MAX_BUFFERED_EVENTS) events = events.slice(events.length - MAX_BUFFERED_EVENTS);
383
+ for (const res of panelClients) sseWrite(res, { id: stored.seq, data: stored });
384
+ return stored;
385
+ }
386
+
387
+ function openStream(req, res, onClose) {
388
+ res.writeHead(200, {
389
+ "Content-Type": "text/event-stream",
390
+ // no-store, not just no-cache: a stream that never finishes would
391
+ // otherwise hold Chrome's HTTP-cache lock for its URL, and a second
392
+ // identical GET (a second leetcode tab asking for /panel/events?after=0)
393
+ // queues behind it forever.
394
+ "Cache-Control": "no-store, no-transform",
395
+ Connection: "keep-alive",
396
+ "X-Accel-Buffering": "no",
397
+ ...corsHeaders(req),
398
+ });
399
+ res.write("retry: 2000\n\n");
400
+ const heartbeat = setInterval(() => res.write(": ping\n\n"), HEARTBEAT_MS);
401
+ heartbeat.unref();
402
+ req.on("close", () => {
403
+ clearInterval(heartbeat);
404
+ onClose();
405
+ });
406
+ }
407
+
408
+ // A body past maxBytes is answered with a 413, but the rest of it is still
409
+ // read and thrown away (up to a hard ceiling) before the connection closes:
410
+ // resetting the socket while the client is still uploading makes it see a
411
+ // network error instead of the 413 (the panel would then say the relay is
412
+ // unreachable rather than that the message is too long).
413
+ function readBody(req, res, maxBytes, onBody) {
414
+ let received = 0;
415
+ const chunks = [];
416
+ let aborted = false;
417
+ req.on("data", (chunk) => {
418
+ received += chunk.length;
419
+ if (aborted) {
420
+ if (received > maxBytes * 4) req.destroy();
421
+ return;
422
+ }
423
+ if (received > maxBytes) {
424
+ aborted = true;
425
+ chunks.length = 0;
426
+ res.setHeader("Connection", "close");
427
+ sendJson(req, res, 413, { error: "payload too large" });
428
+ return;
429
+ }
430
+ chunks.push(chunk);
431
+ });
432
+ req.on("end", () => {
433
+ if (aborted) return;
434
+ let body;
435
+ try {
436
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
437
+ } catch {
438
+ sendJson(req, res, 400, { error: "invalid JSON" });
439
+ return;
440
+ }
441
+ onBody(body);
442
+ });
443
+ req.on("error", () => {});
444
+ }
445
+
446
+ // Sends a settings call down the companion's inbox and answers the request
447
+ // with `answer(response)` -> [status, body] once the companion has, or 504
448
+ // after `timeoutMs`.
449
+ function forwardRpc(req, res, method, params, answer, timeoutMs = rpcTimeoutMs) {
450
+ if (consumers.size === 0) {
451
+ sendJson(req, res, 503, { ok: false, error: { message: "The tutor isn't running." } });
452
+ return;
453
+ }
454
+ if (pendingRpcs.size >= MAX_PENDING_RPCS) {
455
+ sendJson(req, res, 503, { ok: false, error: { message: "Too many settings calls are waiting. Try again." } });
456
+ return;
457
+ }
458
+ const id = crypto.randomBytes(12).toString("hex");
459
+ const timer = setTimeout(() => {
460
+ pendingRpcs.delete(id);
461
+ sendJson(req, res, 504, {
462
+ ok: false,
463
+ error: { message: `The tutor did not answer within ${Math.round(timeoutMs / 1000)} seconds.` },
464
+ });
465
+ }, timeoutMs);
466
+ timer.unref();
467
+ pendingRpcs.set(id, { req, res, timer, answer });
468
+ res.on("close", () => {
469
+ const pending = pendingRpcs.get(id);
470
+ if (pending && pending.res === res) {
471
+ clearTimeout(pending.timer);
472
+ pendingRpcs.delete(id);
473
+ }
474
+ });
475
+ const message = { type: "rpc", id, method, params };
476
+ for (const consumer of consumers) sseWrite(consumer, { data: message });
477
+ }
478
+
479
+ // What a panel hears back from a model switch: nothing but whether it
480
+ // worked, why not, and the non-secret summary of the model in use.
481
+ function switchAnswer(response) {
482
+ if (response.ok) {
483
+ const result = response.result || {};
484
+ const active = sanitizeConfig(result.active);
485
+ const body = { ok: true, profile: typeof result.profile === "string" && PROFILE_ID_RE.test(result.profile) ? result.profile : null };
486
+ if (active) body.active = { provider: active.provider, label: active.label, model: active.model };
487
+ return [200, body];
488
+ }
489
+ const error = response.error || {};
490
+ const code = typeof error.code === "string" && SWITCH_STATUS[error.code] ? error.code : "failed";
491
+ const message = typeof error.message === "string" ? stripControlChars(error.message).slice(0, 300) : "The switch failed.";
492
+ return [SWITCH_STATUS[code] || 500, { ok: false, error: { code, message } }];
493
+ }
494
+
495
+ // POST /settings/rpc from the extension's options page (see the file
496
+ // header). Returns true once handled.
497
+ function handleSettings(req, res, url) {
498
+ if (!allowedHosts.has(req.headers.host)) {
499
+ sendJson(req, res, 403, { error: "forbidden host" });
500
+ return true;
501
+ }
502
+ const origin = req.headers.origin;
503
+ if (typeof origin !== "string" || !EXTENSION_ORIGIN_RE.test(origin)) {
504
+ sendJson(req, res, 403, { error: "this route is for the extension's settings page only" });
505
+ return true;
506
+ }
507
+ const fetchMode = req.headers["sec-fetch-mode"];
508
+ if (fetchMode !== undefined && fetchMode !== "cors") {
509
+ sendJson(req, res, 403, { error: "only CORS requests are accepted" });
510
+ return true;
511
+ }
512
+ if (url.pathname !== "/settings/rpc") {
513
+ sendJson(req, res, 404, { error: "not found" });
514
+ return true;
515
+ }
516
+ if (req.method === "OPTIONS") {
517
+ res.writeHead(204, settingsCorsHeaders(req));
518
+ res.end();
519
+ return true;
520
+ }
521
+ if (req.method !== "POST") {
522
+ sendJson(req, res, 404, { error: "not found" });
523
+ return true;
524
+ }
525
+ readBody(req, res, MAX_RPC_BYTES, (body) => {
526
+ const method = body && body.method;
527
+ const params = body && body.params;
528
+ if (typeof method !== "string" || !RPC_METHOD_RE.test(method) || (params !== undefined && (params === null || typeof params !== "object"))) {
529
+ sendJson(req, res, 400, { error: "a settings `method` and optional `params` object are required" });
530
+ return;
531
+ }
532
+ forwardRpc(req, res, method, params || {}, (response) => [200, response]);
533
+ });
534
+ return true;
535
+ }
536
+
537
+ // Returns true if the request was a /panel/ or /settings/ route (and has
538
+ // been handled), false if the caller should keep routing it.
539
+ function handle(req, res) {
540
+ const url = new URL(req.url, "http://relay.invalid");
541
+ if (url.pathname.startsWith("/settings/")) return handleSettings(req, res, url);
542
+ if (!url.pathname.startsWith("/panel/")) return false;
543
+
544
+ if (!allowedHosts.has(req.headers.host)) {
545
+ sendJson(req, res, 403, { error: "forbidden host" });
546
+ return true;
547
+ }
548
+ const origin = req.headers.origin;
549
+ if (origin !== undefined && origin !== PANEL_ORIGIN) {
550
+ sendJson(req, res, 403, { error: "forbidden origin" });
551
+ return true;
552
+ }
553
+
554
+ // Every legitimate caller makes a CORS-mode request: the panel's fetch
555
+ // from leetcode.com, and the companion's Node fetch (which sends
556
+ // Sec-Fetch-Mode: cors too). A no-cors fetch, an <img>/<script> tag or an
557
+ // <iframe> on some other website sends no Origin, so the check above lets
558
+ // it through, and although it cannot read the stream it holds one of the
559
+ // MAX_PANEL_STREAMS slots: twenty of them locked leetcode.com out with a
560
+ // 503. Refused here, before anything is counted. A browser too old to send
561
+ // Sec-Fetch-Mode at all is not covered; every browser the extension
562
+ // supports sends it.
563
+ const fetchMode = req.headers["sec-fetch-mode"];
564
+ if (fetchMode !== undefined && fetchMode !== "cors") {
565
+ sendJson(req, res, 403, { error: "only CORS requests are accepted" });
566
+ return true;
567
+ }
568
+
569
+ if (req.method === "OPTIONS") {
570
+ res.writeHead(204, corsHeaders(req));
571
+ res.end();
572
+ return true;
573
+ }
574
+
575
+ const route = `${req.method} ${url.pathname}`;
576
+ if (COMPANION_ONLY_ROUTES.has(route)) {
577
+ const rejection = companionOnlyRejection(req);
578
+ if (rejection) {
579
+ sendJson(req, res, 403, { error: rejection });
580
+ return true;
581
+ }
582
+ }
583
+
584
+ if (route === "GET /panel/events") {
585
+ if (panelClients.size >= MAX_PANEL_STREAMS) {
586
+ sendJson(req, res, 503, { error: "too many open panel streams" });
587
+ return true;
588
+ }
589
+ const lastEventId = Number(req.headers["last-event-id"]);
590
+ const afterParam = Number(url.searchParams.get("after"));
591
+ let after = Number.isFinite(lastEventId) && lastEventId > 0 ? lastEventId : Number.isFinite(afterParam) ? afterParam : 0;
592
+ // A client counting from a previous run of the relay must get the whole
593
+ // buffer: its `after` is meaningless against this run's numbering.
594
+ const clientEpoch = url.searchParams.get("epoch");
595
+ if (clientEpoch && clientEpoch !== epoch) after = 0;
596
+ openStream(req, res, () => panelClients.delete(res));
597
+ for (const event of events) {
598
+ if (event.seq > after) sseWrite(res, { id: event.seq, data: event });
599
+ }
600
+ if (latestConfig) sseWrite(res, { data: latestConfig });
601
+ sseWrite(res, { data: statusEvent() });
602
+ panelClients.add(res);
603
+ return true;
604
+ }
605
+
606
+ if (route === "GET /panel/inbox") {
607
+ openStream(req, res, () => {
608
+ consumers.delete(res);
609
+ if (consumers.size === 0) {
610
+ closeOpenTurns();
611
+ companionBusy = false;
612
+ }
613
+ broadcastStatus();
614
+ });
615
+ consumers.add(res);
616
+ broadcastStatus();
617
+ return true;
618
+ }
619
+
620
+ if (route === "POST /panel/message") {
621
+ readBody(req, res, MAX_MESSAGE_BYTES, (body) => {
622
+ const text = body && typeof body.text === "string" ? stripControlChars(body.text).trim() : "";
623
+ const slug = body && body.problemSlug;
624
+ if (!text || text.length > MAX_MESSAGE_CHARS || typeof slug !== "string" || !SLUG_RE.test(slug)) {
625
+ sendJson(req, res, 400, { error: "text (1-8000 chars) and a valid problemSlug are required" });
626
+ return;
627
+ }
628
+ if (consumers.size === 0) {
629
+ sendJson(req, res, 503, { error: "companion offline" });
630
+ return;
631
+ }
632
+ const message = { id: nextMessageId++, text, problemSlug: slug };
633
+ const snapshot = sanitizeSnapshot(body.snapshot);
634
+ if (snapshot) message.snapshot = snapshot;
635
+ for (const consumer of consumers) sseWrite(consumer, { id: message.id, data: message });
636
+ sendJson(req, res, 202, { ok: true, id: message.id });
637
+ });
638
+ return true;
639
+ }
640
+
641
+ // The model switcher (see the file header): only an id goes on.
642
+ if (route === "POST /panel/switch-profile") {
643
+ readBody(req, res, 4096, (body) => {
644
+ const id = body && body.id;
645
+ if (typeof id !== "string" || !PROFILE_ID_RE.test(id)) {
646
+ sendJson(req, res, 400, { ok: false, error: { code: "invalid", message: "a saved model's `id` is required" } });
647
+ return;
648
+ }
649
+ forwardRpc(req, res, "switchProfile", { id }, switchAnswer, switchTimeoutMs);
650
+ });
651
+ return true;
652
+ }
653
+
654
+ // The companion moved on from a problem (its tutor context was reset), so
655
+ // that problem's transcript is no longer kept for panels that connect or
656
+ // reload later. Panels already showing it keep what they have.
657
+ if (route === "POST /panel/drop-problem") {
658
+ readBody(req, res, MAX_MESSAGE_BYTES, (body) => {
659
+ const slug = body && body.problemSlug;
660
+ if (typeof slug !== "string" || !SLUG_RE.test(slug)) {
661
+ sendJson(req, res, 400, { error: "a valid problemSlug is required" });
662
+ return;
663
+ }
664
+ const before = events.length;
665
+ // Chunks carry no slug of their own, only their turn's id.
666
+ const turnIds = new Set(events.filter((e) => e.problemSlug === slug && e.turnId).map((e) => e.turnId));
667
+ events = events.filter((e) => e.problemSlug !== slug && !(e.turnId && turnIds.has(e.turnId)));
668
+ for (const [turnId, turn] of openTurns) if (turn.problemSlug === slug) openTurns.delete(turnId);
669
+ sendJson(req, res, 200, { ok: true, dropped: before - events.length });
670
+ });
671
+ return true;
672
+ }
673
+
674
+ // The companion has read and handled the capture log up to `before` and
675
+ // asks for that prefix to go (see server.js, trimCaptureLog). It moves
676
+ // its own read offset back by `removed` only once this answers 200.
677
+ if (route === "POST /panel/trim-captures") {
678
+ if (!captureLog) {
679
+ sendJson(req, res, 404, { error: "not found" });
680
+ return true;
681
+ }
682
+ readBody(req, res, 4096, (body) => {
683
+ const before = body && body.before;
684
+ if (!Number.isSafeInteger(before) || before < 0 || typeof body.path !== "string" || !body.path) {
685
+ sendJson(req, res, 400, { error: "a byte offset `before` and the log's `path` are required" });
686
+ return;
687
+ }
688
+ if (!sameFile(body.path, captureLog.path)) {
689
+ sendJson(req, res, 409, { error: "this relay writes a different capture log" });
690
+ return;
691
+ }
692
+ let result;
693
+ try {
694
+ result = captureLog.trim(before);
695
+ } catch (err) {
696
+ sendJson(req, res, 500, { error: `could not trim the capture log: ${err.message}` });
697
+ return;
698
+ }
699
+ if (result.error) {
700
+ sendJson(req, res, 409, { error: result.error });
701
+ return;
702
+ }
703
+ sendJson(req, res, 200, { ok: true, removed: result.removed, size: result.size });
704
+ });
705
+ return true;
706
+ }
707
+
708
+ if (route === "POST /panel/activity") {
709
+ readBody(req, res, 4096, (body) => {
710
+ if (!body || typeof body.busy !== "boolean") {
711
+ sendJson(req, res, 400, { error: "a boolean `busy` is required" });
712
+ return;
713
+ }
714
+ if (companionBusy !== body.busy) {
715
+ companionBusy = body.busy;
716
+ broadcastStatus();
717
+ }
718
+ sendJson(req, res, 200, { ok: true });
719
+ });
720
+ return true;
721
+ }
722
+
723
+ // The companion's answer to a settings call (see handleSettings). The
724
+ // first answer wins; a late or unknown one gets a 404.
725
+ if (route === "POST /panel/rpc-result") {
726
+ readBody(req, res, MAX_RPC_RESULT_BYTES, (body) => {
727
+ const id = body && body.id;
728
+ const response = body && body.response;
729
+ if (typeof id !== "string" || !response || typeof response !== "object" || typeof response.ok !== "boolean") {
730
+ sendJson(req, res, 400, { error: "an `id` and a `response` with a boolean `ok` are required" });
731
+ return;
732
+ }
733
+ const pending = pendingRpcs.get(id);
734
+ if (!pending) {
735
+ sendJson(req, res, 404, { error: "no settings call is waiting for that id" });
736
+ return;
737
+ }
738
+ pendingRpcs.delete(id);
739
+ clearTimeout(pending.timer);
740
+ const [status, answer] = pending.answer(response);
741
+ sendJson(pending.req, pending.res, status, answer);
742
+ sendJson(req, res, 200, { ok: true });
743
+ });
744
+ return true;
745
+ }
746
+
747
+ if (route === "POST /panel/publish") {
748
+ readBody(req, res, MAX_PUBLISH_BYTES, (body) => {
749
+ const rawEvents = body && Array.isArray(body.events) ? body.events : [body];
750
+ const clean = rawEvents.map((raw) => (raw && raw.type === "config" ? sanitizeConfig(raw) : sanitizeEvent(raw)));
751
+ if (clean.length === 0 || clean.some((e) => e === null)) {
752
+ sendJson(req, res, 400, { error: "invalid event" });
753
+ return;
754
+ }
755
+ // A config event replaces the previous one and goes straight to every
756
+ // panel; it is not part of the conversation buffer.
757
+ const stored = [];
758
+ for (const event of clean) {
759
+ if (event.type === "config") {
760
+ latestConfig = { ...event, epoch };
761
+ for (const client of panelClients) sseWrite(client, { data: latestConfig });
762
+ } else {
763
+ stored.push(addEvent(event));
764
+ }
765
+ }
766
+ // A publish can land after the companion's inbox stream closed: a
767
+ // companion that exits sends its last batch and drops its stream at
768
+ // the same moment, in either order. With no companion connected
769
+ // nothing will end what this batch started or continued, so it is
770
+ // closed again right away.
771
+ if (consumers.size === 0) closeOpenTurns();
772
+ sendJson(req, res, 200, { ok: true, lastSeq: stored.length ? stored[stored.length - 1].seq : nextSeq - 1 });
773
+ });
774
+ return true;
775
+ }
776
+
777
+ sendJson(req, res, 404, { error: "not found" });
778
+ return true;
779
+ }
780
+
781
+ // How many panels are following the event stream right now (the relay's
782
+ // idle exit counts an open stream as activity).
783
+ return { handle, openPanelStreams: () => panelClients.size };
784
+ }
785
+
786
+ module.exports = {
787
+ createPanelBroker,
788
+ RPC_TIMEOUT_MS,
789
+ SWITCH_RPC_TIMEOUT_MS,
790
+ sanitizeEvent,
791
+ sanitizeConfig,
792
+ stripControlChars,
793
+ companionOnlyRejection,
794
+ EXTENSION_ORIGIN_RE,
795
+ PANEL_ORIGIN,
796
+ COMPANION_HEADER,
797
+ };