@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.
- package/CHANGELOG.md +170 -1
- 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 +70 -3
- package/dist/audit.js +664 -82
- 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 +128 -23
- 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/fleet-health.d.ts +23 -0
- package/dist/fleet-health.js +56 -0
- 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 +246 -57
- package/dist/index.js +105 -35
- 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 +30 -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,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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
});
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
355
|
+
function closeListener() {
|
|
260
356
|
return new Promise((resolve) => {
|
|
261
357
|
if (!serverInstance)
|
|
262
358
|
return resolve();
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 = {
|