@compr/opscontext-mcp 2.9.1 → 2.11.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.
@@ -29,6 +29,89 @@ const HOST = "127.0.0.1";
29
29
  const SECRET_FILE = join(homedir(), ".contextengine", "extension-secret");
30
30
  const MAX_BODY = 64 * 1024; // 64 KB per batch
31
31
  const MAX_BATCH = 50;
32
+ // [LOCKED] [RECEIVER-ACCEPTS-ONLY-KNOWN-SENDERS] - 2026-09-25
33
+ // [NEVER] widen the accepted kinds back to a prefix, accept a reserved actor, answer a web page's
34
+ // origin, or drop the rate cap.
35
+ // WHY: E2E_REVIEW_2026-09 A2-2 to A2-4, measured on a sandbox receiver: any web page could read
36
+ // /health (Access-Control-Allow-Origin: *, and any Host header answered, the DNS-rebinding
37
+ // shape), so a site could tell OpsContext was installed; a sender holding the secret could
38
+ // write `cli.anything`, a bare `vscode.`, and actor "system"; and one local sender wrote
39
+ // 10,000 valid records in 1.36 s (+12.8 MB) with nothing to slow it, noise that verifies as
40
+ // a valid chain and would bury real events past rotation in seconds.
41
+ // FIX: exactly the capture kinds the hook and the extensions send (CAPTURE_EVENT_KINDS); an actor
42
+ // is a short slug and never "system" or "cli" (the server's and the CLI's own names); CORS
43
+ // headers only for browser-extension origins (the extension posts from its service worker,
44
+ // which needs none); Host must name this machine; a token bucket of BURST records refilled
45
+ // at RATE per second, the excess refused with 429 (the extension keeps and retries a refused
46
+ // batch) and counted in one ingest.rate_limited record per minute.
47
+ export const CAPTURE_EVENT_KINDS = [
48
+ "browser.prompt",
49
+ "browser.response",
50
+ "browser.tool_call",
51
+ "browser.session_start",
52
+ "browser.session_end",
53
+ "browser.capture_miss",
54
+ "vscode.prompt_submit",
55
+ "vscode.tool_call",
56
+ "vscode.session_start",
57
+ ];
58
+ const RESERVED_ACTORS = new Set(["system", "cli"]);
59
+ const ACTOR_RE = /^[a-z][a-z0-9-]{1,31}$/;
60
+ const RATE_PER_SEC = 200;
61
+ const BURST = 1000;
62
+ /** An event kind and actor the door accepts, or why not. Shared with `contextengine emit-event`. */
63
+ export function checkCaptureEvent(kind, actor, opts = {}) {
64
+ if (typeof kind !== "string" || !CAPTURE_EVENT_KINDS.includes(kind)) {
65
+ return `event kind '${String(kind)}' not accepted (one of: ${CAPTURE_EVENT_KINDS.join(", ")})`;
66
+ }
67
+ if (actor === undefined)
68
+ return null;
69
+ if (typeof actor !== "string" || !ACTOR_RE.test(actor))
70
+ return `actor must be a short lowercase name`;
71
+ if (RESERVED_ACTORS.has(actor) && !(opts.allowCliActor && actor === "cli"))
72
+ return `actor '${actor}' is reserved`;
73
+ return null;
74
+ }
75
+ /** Origins that may read responses: browser extensions only, never a web page. */
76
+ function corsHeaders(req) {
77
+ const origin = req.headers.origin;
78
+ if (typeof origin !== "string" || !/^(?:chrome|moz|safari-web)-extension:\/\/[\w.-]+$/.test(origin))
79
+ return {};
80
+ return {
81
+ "Access-Control-Allow-Origin": origin,
82
+ Vary: "Origin",
83
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
84
+ "Access-Control-Allow-Headers": "Content-Type, X-OpsContext-Secret",
85
+ };
86
+ }
87
+ /** A Host header that names this machine, or none (not a browser). */
88
+ function hostIsLocal(req, port) {
89
+ const host = req.headers.host;
90
+ if (host === undefined)
91
+ return true;
92
+ return [`127.0.0.1:${port}`, `localhost:${port}`].includes(host.toLowerCase());
93
+ }
94
+ const bucket = { tokens: BURST, at: Date.now(), dropped: 0, droppedSince: "" };
95
+ /** Takes `n` tokens if there are enough; refills at RATE_PER_SEC up to BURST. */
96
+ function takeTokens(n) {
97
+ const now = Date.now();
98
+ bucket.tokens = Math.min(BURST, bucket.tokens + ((now - bucket.at) / 1000) * RATE_PER_SEC);
99
+ bucket.at = now;
100
+ if (bucket.tokens < n) {
101
+ if (bucket.dropped === 0)
102
+ bucket.droppedSince = new Date(now).toISOString();
103
+ bucket.dropped += n;
104
+ return false;
105
+ }
106
+ bucket.tokens -= n;
107
+ return true;
108
+ }
109
+ function flushDroppedCount() {
110
+ if (bucket.dropped === 0)
111
+ return;
112
+ safeAppend("ingest.rate_limited", { dropped: bucket.dropped, since: bucket.droppedSince, rate_per_sec: RATE_PER_SEC, burst: BURST });
113
+ bucket.dropped = 0;
114
+ }
32
115
  let serverInstance = null;
33
116
  /** Hot-reload the secret from disk so the CLI can rotate without restarting MCP. */
34
117
  function loadSecret() {
@@ -66,13 +149,10 @@ function validateEvent(e, idx) {
66
149
  return `events[${idx}]: missing ts`;
67
150
  if (typeof e.payload !== "object" || e.payload === null)
68
151
  return `events[${idx}]: missing payload object`;
69
- // Restrict event kinds to the browser.* + vscode.* + cli.* namespaces.
70
- // The audit module's own writers use other kinds (learning.save etc.);
71
- // those events come from the LOCAL server, not the network surface.
72
- if (!/^(browser|vscode|cli)\./.test(e.event)) {
73
- return `events[${idx}]: event kind '${e.event}' not allowed via HTTP (only browser.*/vscode.*/cli.*)`;
74
- }
75
- return null;
152
+ // Only the capture kinds, never the audit module's own (learning.save, audit.redact, ...), which
153
+ // come from the LOCAL server. [LOCK] [RECEIVER-ACCEPTS-ONLY-KNOWN-SENDERS]
154
+ const err = checkCaptureEvent(e.event, e.actor);
155
+ return err ? `events[${idx}]: ${err}` : null;
76
156
  }
77
157
  /**
78
158
  * The payload as it will be written: every string redacted, and the per-shape counts recorded
@@ -108,24 +188,22 @@ function dropPromptText(payload, event) {
108
188
  text_fingerprint: textFingerprint(text),
109
189
  };
110
190
  }
111
- function sendJson(res, status, body) {
191
+ function sendJson(req, res, status, body, extra = {}) {
112
192
  const json = JSON.stringify(body);
113
193
  res.writeHead(status, {
114
194
  "Content-Type": "application/json",
115
195
  "Content-Length": Buffer.byteLength(json),
116
- // Belt-and-braces: even though the manifest's host_permissions already
117
- // lets the SW POST without preflight, we set the response header so
118
- // future popup-side probe pings work too.
119
- "Access-Control-Allow-Origin": "*",
120
- "Access-Control-Allow-Methods": "POST, OPTIONS",
121
- "Access-Control-Allow-Headers": "Content-Type, X-OpsContext-Secret",
196
+ // Only a browser extension's origin gets CORS headers; the service worker needs none (its
197
+ // host_permissions cover the endpoint). [LOCK] [RECEIVER-ACCEPTS-ONLY-KNOWN-SENDERS]
198
+ ...corsHeaders(req),
199
+ ...extra,
122
200
  });
123
201
  res.end(json);
124
202
  }
125
203
  function handleEvents(req, res) {
126
204
  const secret = loadSecret();
127
205
  if (!secret) {
128
- sendJson(res, 401, {
206
+ sendJson(req, res, 401, {
129
207
  ok: false,
130
208
  error: "no_secret_configured",
131
209
  hint: "Run: contextengine init-extension-secret",
@@ -134,91 +212,105 @@ function handleEvents(req, res) {
134
212
  }
135
213
  const provided = req.headers["x-opscontext-secret"];
136
214
  if (typeof provided !== "string" || !constantTimeEqual(provided, secret)) {
137
- sendJson(res, 401, { ok: false, error: "bad_secret" });
215
+ sendJson(req, res, 401, { ok: false, error: "bad_secret" });
138
216
  return;
139
217
  }
140
218
  let bytes = 0;
219
+ let tooLarge = false;
141
220
  const chunks = [];
142
221
  req.on("data", (chunk) => {
222
+ if (tooLarge)
223
+ return;
143
224
  bytes += chunk.length;
144
225
  if (bytes > MAX_BODY) {
145
- req.destroy();
146
- sendJson(res, 413, { ok: false, error: "payload_too_large", limit: MAX_BODY });
226
+ // Answer first, then drop the connection: destroying before the reply sent the client a
227
+ // bare reset instead of the 413 (E2E_REVIEW_2026-09 A2-5).
228
+ tooLarge = true;
229
+ chunks.length = 0;
230
+ res.on("finish", () => req.destroy());
231
+ sendJson(req, res, 413, { ok: false, error: "payload_too_large", limit: MAX_BODY }, { Connection: "close" });
147
232
  return;
148
233
  }
149
234
  chunks.push(chunk);
150
235
  });
151
236
  req.on("end", () => {
237
+ if (tooLarge)
238
+ return;
152
239
  let batch;
153
240
  try {
154
241
  batch = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
155
242
  }
156
243
  catch {
157
- sendJson(res, 400, { ok: false, error: "bad_json" });
244
+ sendJson(req, res, 400, { ok: false, error: "bad_json" });
158
245
  return;
159
246
  }
160
247
  if (!batch || !Array.isArray(batch.events)) {
161
- sendJson(res, 400, { ok: false, error: "missing_events_array" });
248
+ sendJson(req, res, 400, { ok: false, error: "missing_events_array" });
162
249
  return;
163
250
  }
164
251
  if (batch.events.length > MAX_BATCH) {
165
- sendJson(res, 400, { ok: false, error: "batch_too_large", limit: MAX_BATCH });
252
+ sendJson(req, res, 400, { ok: false, error: "batch_too_large", limit: MAX_BATCH });
166
253
  return;
167
254
  }
168
255
  // Validate every event BEFORE writing any of them.
169
256
  for (let i = 0; i < batch.events.length; i++) {
170
257
  const err = validateEvent(batch.events[i], i);
171
258
  if (err) {
172
- sendJson(res, 400, { ok: false, error: "invalid_event", detail: err });
259
+ sendJson(req, res, 400, { ok: false, error: "invalid_event", detail: err });
173
260
  return;
174
261
  }
175
262
  }
263
+ // [LOCK] [RECEIVER-ACCEPTS-ONLY-KNOWN-SENDERS]: a brake on any one flood, a runaway hook included.
264
+ if (!takeTokens(batch.events.length)) {
265
+ sendJson(req, res, 429, { ok: false, error: "rate_limited", retry_after_ms: 1000 }, { "Retry-After": "1" });
266
+ return;
267
+ }
176
268
  // All valid — write them to audit log via safeAppend.
269
+ // [LOCK] [RECEIVER-SAYS-WHAT-WAS-WRITTEN] (src/cli.ts emit-event): count what the log took, and
270
+ // answer 503 when it refused any. On 2026-09-27 a stuck log answered {"ok":true,"written":25}
271
+ // with 0 of 25 recorded (E2E_REVIEW_2026-09 B1-1).
177
272
  let written = 0;
273
+ let failed = 0;
178
274
  for (const ev of batch.events) {
179
275
  const actor = typeof ev.actor === "string" ? ev.actor : "browser-ext";
180
276
  // event/payload were validated above — cast is safe.
181
277
  // [LOCK] [CAPTURE-IS-REDACTED-AT-THE-DOOR]: redact before the append, never after.
182
- safeAppend(ev.event, prepareCapturedPayload(ev.payload, ev.event), actor);
183
- written++;
278
+ if (safeAppend(ev.event, prepareCapturedPayload(ev.payload, ev.event), actor))
279
+ written++;
280
+ else
281
+ failed++;
282
+ }
283
+ if (failed > 0) {
284
+ sendJson(req, res, 503, { ok: false, error: "audit_append_failed", written, failed });
285
+ return;
184
286
  }
185
- sendJson(res, 200, { ok: true, written });
287
+ sendJson(req, res, 200, { ok: true, written });
186
288
  });
187
289
  }
188
- function handleHealth(_req, res) {
189
- sendJson(res, 200, {
290
+ function handleHealth(req, res) {
291
+ sendJson(req, res, 200, {
190
292
  ok: true,
191
293
  service: "opscontext-event-ingest",
192
294
  port: PORT,
193
295
  secretConfigured: loadSecret() !== null,
194
296
  });
195
297
  }
196
- function handleOptions(_req, res) {
197
- // CORS preflight — the SW shouldn't need this thanks to host_permissions,
198
- // but answering it cleanly costs nothing and helps popup probes.
199
- res.writeHead(204, {
200
- "Access-Control-Allow-Origin": "*",
201
- "Access-Control-Allow-Methods": "POST, OPTIONS",
202
- "Access-Control-Allow-Headers": "Content-Type, X-OpsContext-Secret",
203
- "Access-Control-Max-Age": "600",
204
- });
298
+ function handleOptions(req, res) {
299
+ // CORS preflight: answered for a browser extension's origin only.
300
+ res.writeHead(204, { ...corsHeaders(req), ...(req.headers.origin ? { "Access-Control-Max-Age": "600" } : {}) });
205
301
  res.end();
206
302
  }
207
- /**
208
- * Boot the local event-ingest HTTP server. Returns the listening port or null
209
- * if the port was already in use (caller may decide whether to retry on
210
- * another port or surface the error).
211
- *
212
- * Safe to call multiple times — second call returns the existing server.
213
- */
214
- export function startEventIngestServer() {
215
- if (serverInstance) {
216
- const addr = serverInstance.address();
217
- return Promise.resolve(typeof addr === "object" && addr ? addr.port : PORT);
218
- }
303
+ let retryTimer = null;
304
+ let handoverTimer = null;
305
+ let rateTimer = null;
306
+ let warnedInUse = false;
307
+ /** One bind attempt. Resolves the port, or null when the port is taken or the bind failed. */
308
+ function listenOnce() {
219
309
  return new Promise((resolve) => {
220
310
  const srv = http.createServer((req, res) => {
221
311
  try {
312
+ if (!hostIsLocal(req, PORT))
313
+ return sendJson(req, res, 403, { ok: false, error: "bad_host" });
222
314
  if (req.method === "OPTIONS")
223
315
  return handleOptions(req, res);
224
316
  const url = req.url || "/";
@@ -226,12 +318,12 @@ export function startEventIngestServer() {
226
318
  return handleEvents(req, res);
227
319
  if (req.method === "GET" && url.startsWith("/health"))
228
320
  return handleHealth(req, res);
229
- sendJson(res, 404, { ok: false, error: "not_found" });
321
+ sendJson(req, res, 404, { ok: false, error: "not_found" });
230
322
  }
231
323
  catch (err) {
232
324
  console.error("[ContextEngine] event-ingest error:", err);
233
325
  try {
234
- sendJson(res, 500, { ok: false, error: "internal" });
326
+ sendJson(req, res, 500, { ok: false, error: "internal" });
235
327
  }
236
328
  catch {
237
329
  /* ignore — response may already be closed */
@@ -240,8 +332,11 @@ export function startEventIngestServer() {
240
332
  });
241
333
  srv.on("error", (err) => {
242
334
  if (err.code === "EADDRINUSE") {
243
- console.error(`[ContextEngine] ⚠ port ${PORT} already in use — browser-event ingest disabled.\n` +
244
- ` Set OPSCONTEXT_EVENT_PORT=<n> to use a different port (must also update extension options).`);
335
+ if (!warnedInUse) {
336
+ warnedInUse = true;
337
+ console.error(`[ContextEngine] ⚠ port ${PORT} already in use — browser-event ingest waits for it (retrying).\n` +
338
+ ` Set OPSCONTEXT_EVENT_PORT=<n> to use a different port (must also update extension options).`);
339
+ }
245
340
  resolve(null);
246
341
  return;
247
342
  }
@@ -250,22 +345,116 @@ export function startEventIngestServer() {
250
345
  });
251
346
  srv.listen(PORT, HOST, () => {
252
347
  serverInstance = srv;
348
+ warnedInUse = false;
253
349
  console.error(`[ContextEngine] 🌐 event-ingest on http://${HOST}:${PORT} ` +
254
350
  (loadSecret() ? "(secret loaded)" : "(NO SECRET — run `contextengine init-extension-secret`)"));
255
351
  resolve(PORT);
256
352
  });
257
353
  });
258
354
  }
259
- export function stopEventIngestServer() {
355
+ function closeListener() {
260
356
  return new Promise((resolve) => {
261
357
  if (!serverInstance)
262
358
  return resolve();
263
- serverInstance.close(() => {
264
- serverInstance = null;
265
- resolve();
266
- });
359
+ const srv = serverInstance;
360
+ serverInstance = null;
361
+ srv.close(() => resolve());
362
+ srv.closeAllConnections?.();
363
+ });
364
+ }
365
+ /**
366
+ * Boot the local event-ingest HTTP server. Resolves the listening port, or null when the port is
367
+ * taken; in that case it keeps retrying in the background (see below).
368
+ *
369
+ * [LOCKED] [EVENT-PORT-BELONGS-TO-THE-DAEMON] - 2026-09-25
370
+ * [NEVER] give up on the port after one failed bind, or keep it in a chat server while the
371
+ * launchd agent is alive.
372
+ * WHY: [AUTOSTART-IS-THE-STANDING-INDEXER] meant the agent to own the port from boot, but it bound
373
+ * once and gave up. On 2026-09-25 the agent (v2.9.1) held nothing while a chat server on a
374
+ * stale 2.9.0 build held :7842: the redaction guarding the audit log ran whichever build had
375
+ * grabbed the port first, for as long as that chat stayed open, and events were dropped
376
+ * silently once it closed. Nothing recorded who held it (E2E_REVIEW_2026-09 A2-1).
377
+ * FIX: the agent retries every few seconds until it binds; any other server retries only while
378
+ * no agent is alive; a chat server that holds the port hands it over as soon as an agent is
379
+ * registered; the holder is written into its registry record, and `contextengine servers`
380
+ * shows it and warns about a stale or non-agent holder.
381
+ *
382
+ * Safe to call multiple times — a second call returns the existing server.
383
+ */
384
+ export function startEventIngestServer(opts = {}) {
385
+ if (serverInstance) {
386
+ const addr = serverInstance.address();
387
+ return Promise.resolve(typeof addr === "object" && addr ? addr.port : PORT);
388
+ }
389
+ const liveDaemon = opts.liveDaemon ?? (() => null);
390
+ const retryMs = opts.retryMs ?? (opts.daemon ? 5_000 : 15_000);
391
+ const handoverMs = opts.handoverMs ?? 10_000;
392
+ let attempting = false;
393
+ const held = (port) => {
394
+ opts.onPortChange?.(port);
395
+ if (!rateTimer) {
396
+ rateTimer = setInterval(flushDroppedCount, 60_000);
397
+ rateTimer.unref();
398
+ }
399
+ if (!opts.daemon && !handoverTimer) {
400
+ handoverTimer = setInterval(() => {
401
+ const agent = liveDaemon();
402
+ if (agent === null || !serverInstance)
403
+ return;
404
+ if (handoverTimer)
405
+ clearInterval(handoverTimer);
406
+ handoverTimer = null;
407
+ console.error(`[ContextEngine] 🌐 handing the event port :${PORT} to the launchd agent (pid ${agent})`);
408
+ void closeListener().then(() => {
409
+ opts.onPortChange?.(null);
410
+ retry();
411
+ });
412
+ }, handoverMs);
413
+ handoverTimer.unref();
414
+ }
415
+ };
416
+ const retry = () => {
417
+ if (retryTimer)
418
+ return;
419
+ retryTimer = setInterval(() => {
420
+ if (serverInstance || attempting)
421
+ return;
422
+ if (!opts.daemon && liveDaemon() !== null)
423
+ return; // the agent's port: leave it to it
424
+ attempting = true;
425
+ void listenOnce().then((port) => {
426
+ attempting = false;
427
+ if (port === null)
428
+ return;
429
+ if (retryTimer)
430
+ clearInterval(retryTimer);
431
+ retryTimer = null;
432
+ held(port);
433
+ });
434
+ }, retryMs);
435
+ retryTimer.unref();
436
+ };
437
+ // A chat server does not take the port from under a live agent, not even at start.
438
+ if (!opts.daemon && liveDaemon() !== null) {
439
+ retry();
440
+ return Promise.resolve(null);
441
+ }
442
+ return listenOnce().then((port) => {
443
+ if (port === null)
444
+ retry();
445
+ else
446
+ held(port);
447
+ return port;
267
448
  });
268
449
  }
450
+ export function stopEventIngestServer() {
451
+ for (const t of [retryTimer, handoverTimer, rateTimer])
452
+ if (t)
453
+ clearInterval(t);
454
+ retryTimer = handoverTimer = rateTimer = null;
455
+ flushDroppedCount();
456
+ return closeListener();
457
+ }
269
458
  // Test helpers (not exported in dist surface in production use — but the
270
459
  // module is small enough that tests can import them directly).
271
460
  export const _internal = {