@compr/opscontext-mcp 2.9.1 → 2.10.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 +122 -0
- package/dist/activation.d.ts +23 -1
- package/dist/activation.js +112 -21
- package/dist/adapters.d.ts +1 -1
- package/dist/adapters.js +6 -5
- package/dist/audit.d.ts +1 -1
- package/dist/ce-home.d.ts +7 -0
- package/dist/ce-home.js +45 -0
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +75 -19
- package/dist/collectors.d.ts +3 -0
- package/dist/collectors.js +37 -6
- package/dist/community-export.js +19 -22
- package/dist/community-sync.d.ts +3 -0
- package/dist/community-sync.js +41 -3
- package/dist/config.d.ts +6 -0
- package/dist/config.js +8 -3
- package/dist/firewall.js +7 -3
- package/dist/framing.d.ts +2 -0
- package/dist/framing.js +13 -0
- package/dist/http-server.d.ts +34 -5
- package/dist/http-server.js +234 -55
- package/dist/index.js +64 -32
- package/dist/install-autostart.js +38 -17
- package/dist/install-claude-hook.d.ts +1 -0
- package/dist/install-claude-hook.js +135 -27
- package/dist/learnings.d.ts +1 -0
- package/dist/learnings.js +18 -1
- package/dist/license-sig.d.ts +9 -2
- package/dist/license-sig.js +35 -7
- package/dist/secret-shapes.d.ts +19 -0
- package/dist/secret-shapes.js +27 -2
- package/dist/server-registry.d.ts +8 -0
- package/dist/server-registry.js +38 -2
- package/dist/trusted-projects.d.ts +10 -0
- package/dist/trusted-projects.js +77 -0
- package/package.json +2 -2
package/dist/http-server.js
CHANGED
|
@@ -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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
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,45 +212,59 @@ 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
|
-
|
|
146
|
-
|
|
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.
|
|
177
269
|
let written = 0;
|
|
178
270
|
for (const ev of batch.events) {
|
|
@@ -182,43 +274,33 @@ function handleEvents(req, res) {
|
|
|
182
274
|
safeAppend(ev.event, prepareCapturedPayload(ev.payload, ev.event), actor);
|
|
183
275
|
written++;
|
|
184
276
|
}
|
|
185
|
-
sendJson(res, 200, { ok: true, written });
|
|
277
|
+
sendJson(req, res, 200, { ok: true, written });
|
|
186
278
|
});
|
|
187
279
|
}
|
|
188
|
-
function handleHealth(
|
|
189
|
-
sendJson(res, 200, {
|
|
280
|
+
function handleHealth(req, res) {
|
|
281
|
+
sendJson(req, res, 200, {
|
|
190
282
|
ok: true,
|
|
191
283
|
service: "opscontext-event-ingest",
|
|
192
284
|
port: PORT,
|
|
193
285
|
secretConfigured: loadSecret() !== null,
|
|
194
286
|
});
|
|
195
287
|
}
|
|
196
|
-
function handleOptions(
|
|
197
|
-
// CORS preflight
|
|
198
|
-
|
|
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
|
-
});
|
|
288
|
+
function handleOptions(req, res) {
|
|
289
|
+
// CORS preflight: answered for a browser extension's origin only.
|
|
290
|
+
res.writeHead(204, { ...corsHeaders(req), ...(req.headers.origin ? { "Access-Control-Max-Age": "600" } : {}) });
|
|
205
291
|
res.end();
|
|
206
292
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
}
|
|
293
|
+
let retryTimer = null;
|
|
294
|
+
let handoverTimer = null;
|
|
295
|
+
let rateTimer = null;
|
|
296
|
+
let warnedInUse = false;
|
|
297
|
+
/** One bind attempt. Resolves the port, or null when the port is taken or the bind failed. */
|
|
298
|
+
function listenOnce() {
|
|
219
299
|
return new Promise((resolve) => {
|
|
220
300
|
const srv = http.createServer((req, res) => {
|
|
221
301
|
try {
|
|
302
|
+
if (!hostIsLocal(req, PORT))
|
|
303
|
+
return sendJson(req, res, 403, { ok: false, error: "bad_host" });
|
|
222
304
|
if (req.method === "OPTIONS")
|
|
223
305
|
return handleOptions(req, res);
|
|
224
306
|
const url = req.url || "/";
|
|
@@ -226,12 +308,12 @@ export function startEventIngestServer() {
|
|
|
226
308
|
return handleEvents(req, res);
|
|
227
309
|
if (req.method === "GET" && url.startsWith("/health"))
|
|
228
310
|
return handleHealth(req, res);
|
|
229
|
-
sendJson(res, 404, { ok: false, error: "not_found" });
|
|
311
|
+
sendJson(req, res, 404, { ok: false, error: "not_found" });
|
|
230
312
|
}
|
|
231
313
|
catch (err) {
|
|
232
314
|
console.error("[ContextEngine] event-ingest error:", err);
|
|
233
315
|
try {
|
|
234
|
-
sendJson(res, 500, { ok: false, error: "internal" });
|
|
316
|
+
sendJson(req, res, 500, { ok: false, error: "internal" });
|
|
235
317
|
}
|
|
236
318
|
catch {
|
|
237
319
|
/* ignore — response may already be closed */
|
|
@@ -240,8 +322,11 @@ export function startEventIngestServer() {
|
|
|
240
322
|
});
|
|
241
323
|
srv.on("error", (err) => {
|
|
242
324
|
if (err.code === "EADDRINUSE") {
|
|
243
|
-
|
|
244
|
-
|
|
325
|
+
if (!warnedInUse) {
|
|
326
|
+
warnedInUse = true;
|
|
327
|
+
console.error(`[ContextEngine] ⚠ port ${PORT} already in use — browser-event ingest waits for it (retrying).\n` +
|
|
328
|
+
` Set OPSCONTEXT_EVENT_PORT=<n> to use a different port (must also update extension options).`);
|
|
329
|
+
}
|
|
245
330
|
resolve(null);
|
|
246
331
|
return;
|
|
247
332
|
}
|
|
@@ -250,22 +335,116 @@ export function startEventIngestServer() {
|
|
|
250
335
|
});
|
|
251
336
|
srv.listen(PORT, HOST, () => {
|
|
252
337
|
serverInstance = srv;
|
|
338
|
+
warnedInUse = false;
|
|
253
339
|
console.error(`[ContextEngine] 🌐 event-ingest on http://${HOST}:${PORT} ` +
|
|
254
340
|
(loadSecret() ? "(secret loaded)" : "(NO SECRET — run `contextengine init-extension-secret`)"));
|
|
255
341
|
resolve(PORT);
|
|
256
342
|
});
|
|
257
343
|
});
|
|
258
344
|
}
|
|
259
|
-
|
|
345
|
+
function closeListener() {
|
|
260
346
|
return new Promise((resolve) => {
|
|
261
347
|
if (!serverInstance)
|
|
262
348
|
return resolve();
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
349
|
+
const srv = serverInstance;
|
|
350
|
+
serverInstance = null;
|
|
351
|
+
srv.close(() => resolve());
|
|
352
|
+
srv.closeAllConnections?.();
|
|
267
353
|
});
|
|
268
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Boot the local event-ingest HTTP server. Resolves the listening port, or null when the port is
|
|
357
|
+
* taken; in that case it keeps retrying in the background (see below).
|
|
358
|
+
*
|
|
359
|
+
* [LOCKED] [EVENT-PORT-BELONGS-TO-THE-DAEMON] - 2026-09-25
|
|
360
|
+
* [NEVER] give up on the port after one failed bind, or keep it in a chat server while the
|
|
361
|
+
* launchd agent is alive.
|
|
362
|
+
* WHY: [AUTOSTART-IS-THE-STANDING-INDEXER] meant the agent to own the port from boot, but it bound
|
|
363
|
+
* once and gave up. On 2026-09-25 the agent (v2.9.1) held nothing while a chat server on a
|
|
364
|
+
* stale 2.9.0 build held :7842: the redaction guarding the audit log ran whichever build had
|
|
365
|
+
* grabbed the port first, for as long as that chat stayed open, and events were dropped
|
|
366
|
+
* silently once it closed. Nothing recorded who held it (E2E_REVIEW_2026-09 A2-1).
|
|
367
|
+
* FIX: the agent retries every few seconds until it binds; any other server retries only while
|
|
368
|
+
* no agent is alive; a chat server that holds the port hands it over as soon as an agent is
|
|
369
|
+
* registered; the holder is written into its registry record, and `contextengine servers`
|
|
370
|
+
* shows it and warns about a stale or non-agent holder.
|
|
371
|
+
*
|
|
372
|
+
* Safe to call multiple times — a second call returns the existing server.
|
|
373
|
+
*/
|
|
374
|
+
export function startEventIngestServer(opts = {}) {
|
|
375
|
+
if (serverInstance) {
|
|
376
|
+
const addr = serverInstance.address();
|
|
377
|
+
return Promise.resolve(typeof addr === "object" && addr ? addr.port : PORT);
|
|
378
|
+
}
|
|
379
|
+
const liveDaemon = opts.liveDaemon ?? (() => null);
|
|
380
|
+
const retryMs = opts.retryMs ?? (opts.daemon ? 5_000 : 15_000);
|
|
381
|
+
const handoverMs = opts.handoverMs ?? 10_000;
|
|
382
|
+
let attempting = false;
|
|
383
|
+
const held = (port) => {
|
|
384
|
+
opts.onPortChange?.(port);
|
|
385
|
+
if (!rateTimer) {
|
|
386
|
+
rateTimer = setInterval(flushDroppedCount, 60_000);
|
|
387
|
+
rateTimer.unref();
|
|
388
|
+
}
|
|
389
|
+
if (!opts.daemon && !handoverTimer) {
|
|
390
|
+
handoverTimer = setInterval(() => {
|
|
391
|
+
const agent = liveDaemon();
|
|
392
|
+
if (agent === null || !serverInstance)
|
|
393
|
+
return;
|
|
394
|
+
if (handoverTimer)
|
|
395
|
+
clearInterval(handoverTimer);
|
|
396
|
+
handoverTimer = null;
|
|
397
|
+
console.error(`[ContextEngine] 🌐 handing the event port :${PORT} to the launchd agent (pid ${agent})`);
|
|
398
|
+
void closeListener().then(() => {
|
|
399
|
+
opts.onPortChange?.(null);
|
|
400
|
+
retry();
|
|
401
|
+
});
|
|
402
|
+
}, handoverMs);
|
|
403
|
+
handoverTimer.unref();
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
const retry = () => {
|
|
407
|
+
if (retryTimer)
|
|
408
|
+
return;
|
|
409
|
+
retryTimer = setInterval(() => {
|
|
410
|
+
if (serverInstance || attempting)
|
|
411
|
+
return;
|
|
412
|
+
if (!opts.daemon && liveDaemon() !== null)
|
|
413
|
+
return; // the agent's port: leave it to it
|
|
414
|
+
attempting = true;
|
|
415
|
+
void listenOnce().then((port) => {
|
|
416
|
+
attempting = false;
|
|
417
|
+
if (port === null)
|
|
418
|
+
return;
|
|
419
|
+
if (retryTimer)
|
|
420
|
+
clearInterval(retryTimer);
|
|
421
|
+
retryTimer = null;
|
|
422
|
+
held(port);
|
|
423
|
+
});
|
|
424
|
+
}, retryMs);
|
|
425
|
+
retryTimer.unref();
|
|
426
|
+
};
|
|
427
|
+
// A chat server does not take the port from under a live agent, not even at start.
|
|
428
|
+
if (!opts.daemon && liveDaemon() !== null) {
|
|
429
|
+
retry();
|
|
430
|
+
return Promise.resolve(null);
|
|
431
|
+
}
|
|
432
|
+
return listenOnce().then((port) => {
|
|
433
|
+
if (port === null)
|
|
434
|
+
retry();
|
|
435
|
+
else
|
|
436
|
+
held(port);
|
|
437
|
+
return port;
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
export function stopEventIngestServer() {
|
|
441
|
+
for (const t of [retryTimer, handoverTimer, rateTimer])
|
|
442
|
+
if (t)
|
|
443
|
+
clearInterval(t);
|
|
444
|
+
retryTimer = handoverTimer = rateTimer = null;
|
|
445
|
+
flushDroppedCount();
|
|
446
|
+
return closeListener();
|
|
447
|
+
}
|
|
269
448
|
// Test helpers (not exported in dist surface in production use — but the
|
|
270
449
|
// module is small enough that tests can import them directly).
|
|
271
450
|
export const _internal = {
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir } from "./config.js";
|
|
5
|
+
import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir, findConfigFileWithOrigin } from "./config.js";
|
|
6
6
|
import { ingestSources } from "./ingest.js";
|
|
7
|
-
import {
|
|
7
|
+
import { redactChunk } from "./secret-shapes.js";
|
|
8
8
|
import { summarizeSource } from "./source-summary.js";
|
|
9
9
|
import { searchChunks } from "./search.js";
|
|
10
10
|
import { initEmbeddings, embedChunks, embedKeyOf, vectorSearch, isEmbeddingsReady, } from "./embeddings.js";
|
|
@@ -14,7 +14,9 @@ import { sharedIndexEnabled, corpusId, electIndexer, writeSharedIndex, readShare
|
|
|
14
14
|
import { listProjects, checkPorts, runComplianceAudit, formatProjectList, formatPortMap, formatPlan, scoreProject, formatScoreReport, runScoreCanary, } from "./agents.js";
|
|
15
15
|
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
16
16
|
import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend } from "./audit.js";
|
|
17
|
-
import { registerServer, listServers, formatServers } from "./server-registry.js";
|
|
17
|
+
import { registerServer, listServers, formatServers, liveDaemonPid } from "./server-registry.js";
|
|
18
|
+
import { secureCeHome } from "./ce-home.js";
|
|
19
|
+
import { QUOTED_TEXT_NOTE } from "./framing.js";
|
|
18
20
|
import { computeFleetHealth, writeFleetHealth } from "./fleet-health.js";
|
|
19
21
|
import { startEventIngestServer } from "./http-server.js";
|
|
20
22
|
import { detect } from "./detector.js";
|
|
@@ -38,7 +40,7 @@ try {
|
|
|
38
40
|
}
|
|
39
41
|
catch { /* fallback */ }
|
|
40
42
|
import { loadAdapters, collectFromAdapters, } from "./adapters.js";
|
|
41
|
-
import {
|
|
43
|
+
import { gateCheckFresh, licenceCheckState, activate, getActivationStatus, } from "./activation.js";
|
|
42
44
|
import { ProtocolFirewall } from "./firewall.js";
|
|
43
45
|
// ---------------------------------------------------------------------------
|
|
44
46
|
// State
|
|
@@ -55,6 +57,8 @@ let role = "indexer";
|
|
|
55
57
|
let corpus;
|
|
56
58
|
let indexerPid = null;
|
|
57
59
|
let setRegistryRole = null;
|
|
60
|
+
let setRegistryEventPort = null;
|
|
61
|
+
let warnedCwdAdapters = false;
|
|
58
62
|
/** key -> vector, loaded from ~/.contextengine/embeddings.bin and grown by what we embed. */
|
|
59
63
|
let vectorStore = new Map();
|
|
60
64
|
let indexSeq = 0;
|
|
@@ -133,6 +137,11 @@ async function buildIndex(opts) {
|
|
|
133
137
|
if (autoImport.refused) {
|
|
134
138
|
console.error(`[ContextEngine] ⛔ Auto-import write refused: ${autoImport.refused}`);
|
|
135
139
|
}
|
|
140
|
+
// [LOCK] [AUTO-IMPORT-ONLY-FROM-TRUSTED-PROJECTS]: say what was left out, and how to include it.
|
|
141
|
+
if (autoImport.untrusted.length > 0) {
|
|
142
|
+
console.error(`[ContextEngine] ⏸ Marked learnings in ${autoImport.untrusted.length} project(s) you have not marked as yours were not imported: ` +
|
|
143
|
+
`${autoImport.untrusted.join(", ")}. If they are yours: contextengine trust ${autoImport.untrusted.map((p) => JSON.stringify(p)).join(" ")}`);
|
|
144
|
+
}
|
|
136
145
|
}
|
|
137
146
|
// Inject learnings as searchable chunks (project-scoped to prevent IP leakage)
|
|
138
147
|
const learningChunks = learningsToChunks(activeProjectNames);
|
|
@@ -156,9 +165,29 @@ async function buildIndex(opts) {
|
|
|
156
165
|
// [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: before the adapters, whose block can return early.
|
|
157
166
|
chunks = chunks.map(redactChunk);
|
|
158
167
|
// Collect from plugin adapters
|
|
159
|
-
|
|
168
|
+
// [LOCKED] [ADAPTERS-ONLY-FROM-THE-USERS-OWN-CONFIG] - 2026-09-25
|
|
169
|
+
// [NEVER] import an adapter module named by a contextengine.json found in the current folder, or
|
|
170
|
+
// resolve a relative adapter path from the current folder.
|
|
171
|
+
// WHY: without CONTEXTENGINE_CONFIG (the README's setup), the server reads ./contextengine.json
|
|
172
|
+
// from the folder it starts in, which for Claude Code is the project opened. An adapter entry
|
|
173
|
+
// there is import()ed, so a downloaded repository carrying a config and a module ran its own
|
|
174
|
+
// code in the user's session when the project was opened; proven in a sandbox on 2026-09-25
|
|
175
|
+
// (E2E_REVIEW_2026-09 A6-5). A relative path in the user's own config also resolved from
|
|
176
|
+
// that folder, not from the config's.
|
|
177
|
+
// FIX: adapters load only from the config named by CONTEXTENGINE_CONFIG or ~/.contextengine.json,
|
|
178
|
+
// relative paths resolve from that file's folder; a config found in the current folder may
|
|
179
|
+
// still list sources, never code.
|
|
180
|
+
const cfgFile = findConfigFileWithOrigin();
|
|
181
|
+
if (config.adapters && config.adapters.length > 0 && cfgFile?.origin === "cwd") {
|
|
182
|
+
if (!warnedCwdAdapters) {
|
|
183
|
+
warnedCwdAdapters = true;
|
|
184
|
+
console.error(`[ContextEngine] ⛔ Adapters in ${cfgFile.path} were NOT loaded: a config found in the current folder may not run code. ` +
|
|
185
|
+
`Point CONTEXTENGINE_CONFIG at it, or move the adapters to ~/.contextengine.json.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (config.adapters && config.adapters.length > 0) {
|
|
160
189
|
if (opts.loadAdapters) {
|
|
161
|
-
const adapterCount = await loadAdapters(config.adapters);
|
|
190
|
+
const adapterCount = await loadAdapters(config.adapters, cfgFile ? dirname(cfgFile.path) : process.cwd());
|
|
162
191
|
if (adapterCount === 0)
|
|
163
192
|
return;
|
|
164
193
|
}
|
|
@@ -169,22 +198,8 @@ async function buildIndex(opts) {
|
|
|
169
198
|
}
|
|
170
199
|
}
|
|
171
200
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
* [NEVER] let a chunk into the index, the shared index file or a search result without passing
|
|
175
|
-
* its text through redactSecrets().
|
|
176
|
-
* WHY: on 2026-09-25 the shared index held database URLs with their passwords (read from dotenv
|
|
177
|
-
* files, whose masking skipped the password inside a URL), sshpass and mysql passwords from
|
|
178
|
-
* runbooks and memory notes, and Google API keys: 55 sources in all. search_context hands
|
|
179
|
-
* chunks to every AI agent that asks, and the index file rides the weekly backup.
|
|
180
|
-
* FIX: every chunk, whatever collected it (docs, code, ops collectors, learnings, community rules,
|
|
181
|
-
* adapters), is redacted with the capture shapes (src/secret-shapes.ts) as the index is built.
|
|
182
|
-
* The source files are not touched: cleaning those is the owner's call, file by file.
|
|
183
|
-
*/
|
|
184
|
-
function redactChunk(c) {
|
|
185
|
-
const r = redactSecrets(c.content);
|
|
186
|
-
return Object.keys(r.counts).length > 0 ? { ...c, content: r.text } : c;
|
|
187
|
-
}
|
|
201
|
+
// [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: redactChunk lives in src/secret-shapes.ts, shared with
|
|
202
|
+
// the CLI's index builder.
|
|
188
203
|
/** Load the model once; every caller shares the same promise. */
|
|
189
204
|
function ensureModel() {
|
|
190
205
|
if (!modelInit)
|
|
@@ -577,6 +592,7 @@ server.tool("search_context", "Search across all indexed project knowledge (copi
|
|
|
577
592
|
const searchMode = isEmbeddingsReady() ? mode : "keyword (embeddings loading)";
|
|
578
593
|
const text = [
|
|
579
594
|
`Search: "${query}" | Mode: ${searchMode} | ${results.length} results`,
|
|
595
|
+
QUOTED_TEXT_NOTE, // [LOCK] [QUOTED-TEXT-IS-FRAMED-AS-DATA]
|
|
580
596
|
"",
|
|
581
597
|
...results.map((r, i) => [
|
|
582
598
|
`--- Result ${i + 1} (${r.label}: ${r.score.toFixed(3)}) ---`,
|
|
@@ -603,7 +619,8 @@ server.tool("list_sources", "List all knowledge sources indexed by ContextEngine
|
|
|
603
619
|
const status = exists
|
|
604
620
|
? `✅ ${count} chunks${embeddedCount > 0 ? ` (${embeddedCount} embedded)` : ""}`
|
|
605
621
|
: "⚠ file not found";
|
|
606
|
-
|
|
622
|
+
// A preview quotes the file: redacted like any chunk. [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]
|
|
623
|
+
const summary = exists ? redactChunk({ content: summarizeSource(s) }).content : "";
|
|
607
624
|
return `${s.name}: ${status}${summary ? `\n ${summary}` : ""}\n ${s.path}`;
|
|
608
625
|
});
|
|
609
626
|
const embStatus = isEmbeddingsReady()
|
|
@@ -612,6 +629,7 @@ server.tool("list_sources", "List all knowledge sources indexed by ContextEngine
|
|
|
612
629
|
const text = [
|
|
613
630
|
`ContextEngine v${PKG_VERSION}`,
|
|
614
631
|
`Sources: ${sources.length} | Chunks: ${chunks.length} | Embeddings: ${embStatus}`,
|
|
632
|
+
QUOTED_TEXT_NOTE, // [LOCK] [QUOTED-TEXT-IS-FRAMED-AS-DATA]
|
|
615
633
|
"",
|
|
616
634
|
...lines,
|
|
617
635
|
].join("\n");
|
|
@@ -648,8 +666,10 @@ server.tool("read_source", "Read the full content of a specific knowledge source
|
|
|
648
666
|
isError: true,
|
|
649
667
|
};
|
|
650
668
|
}
|
|
651
|
-
|
|
652
|
-
|
|
669
|
+
// [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: the whole file goes through the same redaction as a
|
|
670
|
+
// search result; read_source used to return it raw (E2E_REVIEW_2026-09 A6-6).
|
|
671
|
+
const content = redactChunk({ content: readFileSync(source.path, "utf-8") }).content;
|
|
672
|
+
return respond("read_source", `# ${source.name}\n${QUOTED_TEXT_NOTE}\n\n${content}`, source_name);
|
|
653
673
|
});
|
|
654
674
|
// ---------------------------------------------------------------------------
|
|
655
675
|
// Tool: reindex
|
|
@@ -666,7 +686,7 @@ server.tool("reindex", "Force a full re-index of all knowledge sources. Use afte
|
|
|
666
686
|
// Tool: list_projects (Multi-Agent Phase 1)
|
|
667
687
|
// ---------------------------------------------------------------------------
|
|
668
688
|
server.tool("list_projects", "Discover and analyze all projects in the workspace. Shows tech stack (framework, runtime, key dependencies), infrastructure (git, docker, pm2), and git remote status for each project. Requires Pro license.", {}, async () => {
|
|
669
|
-
const gate =
|
|
689
|
+
const gate = await gateCheckFresh("list_projects");
|
|
670
690
|
if (gate)
|
|
671
691
|
return { content: [{ type: "text", text: gate }] };
|
|
672
692
|
const projectDirs = loadProjectDirs();
|
|
@@ -678,7 +698,7 @@ server.tool("list_projects", "Discover and analyze all projects in the workspace
|
|
|
678
698
|
// Tool: check_ports (Multi-Agent Phase 1)
|
|
679
699
|
// ---------------------------------------------------------------------------
|
|
680
700
|
server.tool("check_ports", "Scan all projects for port declarations (ecosystem.config.js, docker-compose.yml, .env, package.json) and detect port conflicts. Returns a port allocation map with conflict warnings. Requires Pro license.", {}, async () => {
|
|
681
|
-
const gate =
|
|
701
|
+
const gate = await gateCheckFresh("check_ports");
|
|
682
702
|
if (gate)
|
|
683
703
|
return { content: [{ type: "text", text: gate }] };
|
|
684
704
|
const projectDirs = loadProjectDirs();
|
|
@@ -695,7 +715,7 @@ server.tool("run_audit", "Run the Compliance Agent audit across all projects. Ch
|
|
|
695
715
|
.default("all")
|
|
696
716
|
.describe("Audit scope: all checks, compliance only, version checks only, or port conflicts only"),
|
|
697
717
|
}, async ({ scope }) => {
|
|
698
|
-
const gate =
|
|
718
|
+
const gate = await gateCheckFresh("run_audit");
|
|
699
719
|
if (gate)
|
|
700
720
|
return { content: [{ type: "text", text: gate }] };
|
|
701
721
|
const projectDirs = loadProjectDirs();
|
|
@@ -712,7 +732,7 @@ server.tool("score_project", "Score one or all projects on AI-readiness (0-100%)
|
|
|
712
732
|
.optional()
|
|
713
733
|
.describe("Project name OR absolute directory path to score. Omit to score all projects."),
|
|
714
734
|
}, async ({ project }) => {
|
|
715
|
-
const gate =
|
|
735
|
+
const gate = await gateCheckFresh("score_project");
|
|
716
736
|
if (gate)
|
|
717
737
|
return { content: [{ type: "text", text: gate }] };
|
|
718
738
|
// 🔒 LOCKED [SCORE-CANARY-COVERS-EVERY-SCORER] — 2026-08-19
|
|
@@ -1169,7 +1189,8 @@ server.tool("list_learnings", "List all permanent learnings, optionally filtered
|
|
|
1169
1189
|
}
|
|
1170
1190
|
const learnings = listLearnings(category, activeProjectNames);
|
|
1171
1191
|
const text = formatLearnings(learnings, { since: sinceDate, sinceSpec: since });
|
|
1172
|
-
|
|
1192
|
+
// Redacted like every chunk: a learning can quote a command with its password. [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]
|
|
1193
|
+
return respond("list_learnings", redactChunk({ content: text }).content);
|
|
1173
1194
|
});
|
|
1174
1195
|
// ---------------------------------------------------------------------------
|
|
1175
1196
|
// Tool: delete_learning (Permanent Learning Store)
|
|
@@ -1265,6 +1286,7 @@ server.tool("activation_status", "Check current ContextEngine license status, pl
|
|
|
1265
1286
|
`- **Expires**: ${status.expiresAt}`,
|
|
1266
1287
|
`- **Delta version**: ${status.deltaVersion}`,
|
|
1267
1288
|
`- **Machine ID**: ${status.machineId}`,
|
|
1289
|
+
`- **Licence check**: ${licenceCheckState()}`, // [LOCK] [LICENSE-IS-CHECKED-DAILY]
|
|
1268
1290
|
``,
|
|
1269
1291
|
];
|
|
1270
1292
|
if (status.premiumTools.length > 0) {
|
|
@@ -1319,6 +1341,9 @@ function registerResources() {
|
|
|
1319
1341
|
// Start
|
|
1320
1342
|
// ---------------------------------------------------------------------------
|
|
1321
1343
|
async function main() {
|
|
1344
|
+
// [LOCK] [CE-HOME-IS-PRIVATE]: the folder is 0700 before the registry, the index or the log write
|
|
1345
|
+
// into it.
|
|
1346
|
+
secureCeHome();
|
|
1322
1347
|
// 0. Inventory this server FIRST, before indexing takes minutes: a server exists the moment it
|
|
1323
1348
|
// starts. [LOCK] [SERVERS-ARE-INVENTORIED]. With the shared index on, the registry is also
|
|
1324
1349
|
// the electorate: the record carries the corpus and the role. [LOCK] [ONE-INDEXER-MANY-READERS]
|
|
@@ -1331,8 +1356,9 @@ async function main() {
|
|
|
1331
1356
|
}
|
|
1332
1357
|
}
|
|
1333
1358
|
try {
|
|
1334
|
-
const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined });
|
|
1359
|
+
const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined, daemon: process.env.OPSCONTEXT_DAEMON === "1" });
|
|
1335
1360
|
setRegistryRole = reg.setRole;
|
|
1361
|
+
setRegistryEventPort = reg.setEventPort;
|
|
1336
1362
|
const fleet = listServers();
|
|
1337
1363
|
if (corpus) {
|
|
1338
1364
|
const e = electIndexer(corpus, fleet.servers, process.pid);
|
|
@@ -1466,7 +1492,13 @@ async function main() {
|
|
|
1466
1492
|
// No-op if secret is missing; the endpoint will refuse with 401 until
|
|
1467
1493
|
// a secret is configured. Failure to bind (port collision) logs and
|
|
1468
1494
|
// continues — the MCP server stays usable without browser capture.
|
|
1469
|
-
|
|
1495
|
+
// [LOCK] [EVENT-PORT-BELONGS-TO-THE-DAEMON]: the launchd agent keeps trying until it holds the
|
|
1496
|
+
// port; a chat server takes it only while no agent is alive, and hands it over when one is.
|
|
1497
|
+
startEventIngestServer({
|
|
1498
|
+
daemon: process.env.OPSCONTEXT_DAEMON === "1",
|
|
1499
|
+
onPortChange: (port) => setRegistryEventPort?.(port),
|
|
1500
|
+
liveDaemon: () => liveDaemonPid(),
|
|
1501
|
+
}).catch((err) => {
|
|
1470
1502
|
console.error("[ContextEngine] event-ingest start failed:", err);
|
|
1471
1503
|
});
|
|
1472
1504
|
}
|