@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/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";
|
|
@@ -13,8 +13,10 @@ import { loadEmbeddingStore, compactEmbeddingStore } from "./embedding-store.js"
|
|
|
13
13
|
import { sharedIndexEnabled, corpusId, electIndexer, writeSharedIndex, readSharedIndex, sharedIndexMtime, } from "./shared-index.js";
|
|
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
|
-
import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend } from "./audit.js";
|
|
17
|
-
import { registerServer, listServers, formatServers } from "./server-registry.js";
|
|
16
|
+
import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend, readVerifyState } from "./audit.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";
|
|
@@ -24,7 +26,8 @@ import { communityRulesToChunks, mergeWithDedup, loadCommunityStore, } from "./c
|
|
|
24
26
|
import { readFileSync, existsSync, watch, statSync, writeFileSync, mkdirSync } from "fs";
|
|
25
27
|
import { basename, join, dirname } from "path";
|
|
26
28
|
import { homedir } from "os";
|
|
27
|
-
import { execSync } from "child_process";
|
|
29
|
+
import { execSync, spawn } from "child_process";
|
|
30
|
+
import { setPriority } from "os";
|
|
28
31
|
import { scanCodeDir } from "./code-chunker.js";
|
|
29
32
|
import { fileURLToPath } from "url";
|
|
30
33
|
import { TOOL_COUNT, FREE_TOOL_COUNT, PREMIUM_TOOL_NAMES } from "./tools-manifest.js";
|
|
@@ -38,7 +41,7 @@ try {
|
|
|
38
41
|
}
|
|
39
42
|
catch { /* fallback */ }
|
|
40
43
|
import { loadAdapters, collectFromAdapters, } from "./adapters.js";
|
|
41
|
-
import {
|
|
44
|
+
import { gateCheckFresh, licenceCheckState, activate, getActivationStatus, } from "./activation.js";
|
|
42
45
|
import { ProtocolFirewall } from "./firewall.js";
|
|
43
46
|
// ---------------------------------------------------------------------------
|
|
44
47
|
// State
|
|
@@ -55,6 +58,8 @@ let role = "indexer";
|
|
|
55
58
|
let corpus;
|
|
56
59
|
let indexerPid = null;
|
|
57
60
|
let setRegistryRole = null;
|
|
61
|
+
let setRegistryEventPort = null;
|
|
62
|
+
let warnedCwdAdapters = false;
|
|
58
63
|
/** key -> vector, loaded from ~/.contextengine/embeddings.bin and grown by what we embed. */
|
|
59
64
|
let vectorStore = new Map();
|
|
60
65
|
let indexSeq = 0;
|
|
@@ -133,6 +138,11 @@ async function buildIndex(opts) {
|
|
|
133
138
|
if (autoImport.refused) {
|
|
134
139
|
console.error(`[ContextEngine] ⛔ Auto-import write refused: ${autoImport.refused}`);
|
|
135
140
|
}
|
|
141
|
+
// [LOCK] [AUTO-IMPORT-ONLY-FROM-TRUSTED-PROJECTS]: say what was left out, and how to include it.
|
|
142
|
+
if (autoImport.untrusted.length > 0) {
|
|
143
|
+
console.error(`[ContextEngine] ⏸ Marked learnings in ${autoImport.untrusted.length} project(s) you have not marked as yours were not imported: ` +
|
|
144
|
+
`${autoImport.untrusted.join(", ")}. If they are yours: contextengine trust ${autoImport.untrusted.map((p) => JSON.stringify(p)).join(" ")}`);
|
|
145
|
+
}
|
|
136
146
|
}
|
|
137
147
|
// Inject learnings as searchable chunks (project-scoped to prevent IP leakage)
|
|
138
148
|
const learningChunks = learningsToChunks(activeProjectNames);
|
|
@@ -156,9 +166,29 @@ async function buildIndex(opts) {
|
|
|
156
166
|
// [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: before the adapters, whose block can return early.
|
|
157
167
|
chunks = chunks.map(redactChunk);
|
|
158
168
|
// Collect from plugin adapters
|
|
159
|
-
|
|
169
|
+
// [LOCKED] [ADAPTERS-ONLY-FROM-THE-USERS-OWN-CONFIG] - 2026-09-25
|
|
170
|
+
// [NEVER] import an adapter module named by a contextengine.json found in the current folder, or
|
|
171
|
+
// resolve a relative adapter path from the current folder.
|
|
172
|
+
// WHY: without CONTEXTENGINE_CONFIG (the README's setup), the server reads ./contextengine.json
|
|
173
|
+
// from the folder it starts in, which for Claude Code is the project opened. An adapter entry
|
|
174
|
+
// there is import()ed, so a downloaded repository carrying a config and a module ran its own
|
|
175
|
+
// code in the user's session when the project was opened; proven in a sandbox on 2026-09-25
|
|
176
|
+
// (E2E_REVIEW_2026-09 A6-5). A relative path in the user's own config also resolved from
|
|
177
|
+
// that folder, not from the config's.
|
|
178
|
+
// FIX: adapters load only from the config named by CONTEXTENGINE_CONFIG or ~/.contextengine.json,
|
|
179
|
+
// relative paths resolve from that file's folder; a config found in the current folder may
|
|
180
|
+
// still list sources, never code.
|
|
181
|
+
const cfgFile = findConfigFileWithOrigin();
|
|
182
|
+
if (config.adapters && config.adapters.length > 0 && cfgFile?.origin === "cwd") {
|
|
183
|
+
if (!warnedCwdAdapters) {
|
|
184
|
+
warnedCwdAdapters = true;
|
|
185
|
+
console.error(`[ContextEngine] ⛔ Adapters in ${cfgFile.path} were NOT loaded: a config found in the current folder may not run code. ` +
|
|
186
|
+
`Point CONTEXTENGINE_CONFIG at it, or move the adapters to ~/.contextengine.json.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else if (config.adapters && config.adapters.length > 0) {
|
|
160
190
|
if (opts.loadAdapters) {
|
|
161
|
-
const adapterCount = await loadAdapters(config.adapters);
|
|
191
|
+
const adapterCount = await loadAdapters(config.adapters, cfgFile ? dirname(cfgFile.path) : process.cwd());
|
|
162
192
|
if (adapterCount === 0)
|
|
163
193
|
return;
|
|
164
194
|
}
|
|
@@ -169,22 +199,8 @@ async function buildIndex(opts) {
|
|
|
169
199
|
}
|
|
170
200
|
}
|
|
171
201
|
}
|
|
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
|
-
}
|
|
202
|
+
// [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: redactChunk lives in src/secret-shapes.ts, shared with
|
|
203
|
+
// the CLI's index builder.
|
|
188
204
|
/** Load the model once; every caller shares the same promise. */
|
|
189
205
|
function ensureModel() {
|
|
190
206
|
if (!modelInit)
|
|
@@ -445,6 +461,42 @@ function evaluateRole(reason) {
|
|
|
445
461
|
}
|
|
446
462
|
let healthTick = 0;
|
|
447
463
|
let lastStaleCount = -1;
|
|
464
|
+
const SERVER_STARTED_AT = Date.now();
|
|
465
|
+
let lastChainCheckSpawn = 0;
|
|
466
|
+
/**
|
|
467
|
+
* The indexing server starts the daily full check of the audit chain, in its own process.
|
|
468
|
+
* [LOCK] [HEALTH-SEES-THE-CHAIN] (src/fleet-health.ts): the check costs 26 s and 3.5 GB on 4.9M
|
|
469
|
+
* records (measured 2026-09-27), so it never runs in this long-lived process, whose event loop
|
|
470
|
+
* serves the receiver and the tools. A separate low-priority `audit-verify --scheduled`, at most
|
|
471
|
+
* one attempt an hour, never in the first 10 minutes after a start, one at a time across processes
|
|
472
|
+
* (its own lock). CONTEXTENGINE_CHAIN_CHECK=0 turns it off.
|
|
473
|
+
*/
|
|
474
|
+
function maybeScheduleChainCheck() {
|
|
475
|
+
if (role !== "indexer" || process.env.CONTEXTENGINE_CHAIN_CHECK === "0")
|
|
476
|
+
return;
|
|
477
|
+
const now = Date.now();
|
|
478
|
+
if (now - SERVER_STARTED_AT < 10 * 60_000 || now - lastChainCheckSpawn < 60 * 60_000)
|
|
479
|
+
return;
|
|
480
|
+
const state = readVerifyState();
|
|
481
|
+
if (state && now - Date.parse(state.checkedAt) < 24 * 3_600_000)
|
|
482
|
+
return;
|
|
483
|
+
lastChainCheckSpawn = now;
|
|
484
|
+
try {
|
|
485
|
+
const cli = join(dirname(fileURLToPath(import.meta.url)), "cli.js");
|
|
486
|
+
const child = spawn(process.execPath, [cli, "audit-verify", "--scheduled"], { stdio: "ignore", detached: true, env: process.env });
|
|
487
|
+
if (child.pid) {
|
|
488
|
+
try {
|
|
489
|
+
setPriority(child.pid, 10);
|
|
490
|
+
}
|
|
491
|
+
catch { /* the check still runs, at normal priority */ }
|
|
492
|
+
}
|
|
493
|
+
child.unref();
|
|
494
|
+
console.error(`[ContextEngine] 🔎 daily audit chain check started (pid ${child.pid ?? "?"})`);
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
console.error(`[ContextEngine] ⚠ could not start the daily audit chain check: ${err.message}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
448
500
|
/**
|
|
449
501
|
* The indexer writes ~/.contextengine/fleet-health.json once a minute: version drift, reindex
|
|
450
502
|
* rate, today's blocks and refusals, the last verified release. Every surface reads that file.
|
|
@@ -460,6 +512,7 @@ function publishHealth() {
|
|
|
460
512
|
}
|
|
461
513
|
if (role === "indexer")
|
|
462
514
|
writeFleetHealth(h);
|
|
515
|
+
maybeScheduleChainCheck();
|
|
463
516
|
}
|
|
464
517
|
catch (err) {
|
|
465
518
|
console.error(`[ContextEngine] ⚠ fleet health failed: ${err.message}`);
|
|
@@ -577,6 +630,7 @@ server.tool("search_context", "Search across all indexed project knowledge (copi
|
|
|
577
630
|
const searchMode = isEmbeddingsReady() ? mode : "keyword (embeddings loading)";
|
|
578
631
|
const text = [
|
|
579
632
|
`Search: "${query}" | Mode: ${searchMode} | ${results.length} results`,
|
|
633
|
+
QUOTED_TEXT_NOTE, // [LOCK] [QUOTED-TEXT-IS-FRAMED-AS-DATA]
|
|
580
634
|
"",
|
|
581
635
|
...results.map((r, i) => [
|
|
582
636
|
`--- Result ${i + 1} (${r.label}: ${r.score.toFixed(3)}) ---`,
|
|
@@ -603,7 +657,8 @@ server.tool("list_sources", "List all knowledge sources indexed by ContextEngine
|
|
|
603
657
|
const status = exists
|
|
604
658
|
? `✅ ${count} chunks${embeddedCount > 0 ? ` (${embeddedCount} embedded)` : ""}`
|
|
605
659
|
: "⚠ file not found";
|
|
606
|
-
|
|
660
|
+
// A preview quotes the file: redacted like any chunk. [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]
|
|
661
|
+
const summary = exists ? redactChunk({ content: summarizeSource(s) }).content : "";
|
|
607
662
|
return `${s.name}: ${status}${summary ? `\n ${summary}` : ""}\n ${s.path}`;
|
|
608
663
|
});
|
|
609
664
|
const embStatus = isEmbeddingsReady()
|
|
@@ -612,6 +667,7 @@ server.tool("list_sources", "List all knowledge sources indexed by ContextEngine
|
|
|
612
667
|
const text = [
|
|
613
668
|
`ContextEngine v${PKG_VERSION}`,
|
|
614
669
|
`Sources: ${sources.length} | Chunks: ${chunks.length} | Embeddings: ${embStatus}`,
|
|
670
|
+
QUOTED_TEXT_NOTE, // [LOCK] [QUOTED-TEXT-IS-FRAMED-AS-DATA]
|
|
615
671
|
"",
|
|
616
672
|
...lines,
|
|
617
673
|
].join("\n");
|
|
@@ -648,8 +704,10 @@ server.tool("read_source", "Read the full content of a specific knowledge source
|
|
|
648
704
|
isError: true,
|
|
649
705
|
};
|
|
650
706
|
}
|
|
651
|
-
|
|
652
|
-
|
|
707
|
+
// [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]: the whole file goes through the same redaction as a
|
|
708
|
+
// search result; read_source used to return it raw (E2E_REVIEW_2026-09 A6-6).
|
|
709
|
+
const content = redactChunk({ content: readFileSync(source.path, "utf-8") }).content;
|
|
710
|
+
return respond("read_source", `# ${source.name}\n${QUOTED_TEXT_NOTE}\n\n${content}`, source_name);
|
|
653
711
|
});
|
|
654
712
|
// ---------------------------------------------------------------------------
|
|
655
713
|
// Tool: reindex
|
|
@@ -666,7 +724,7 @@ server.tool("reindex", "Force a full re-index of all knowledge sources. Use afte
|
|
|
666
724
|
// Tool: list_projects (Multi-Agent Phase 1)
|
|
667
725
|
// ---------------------------------------------------------------------------
|
|
668
726
|
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 =
|
|
727
|
+
const gate = await gateCheckFresh("list_projects");
|
|
670
728
|
if (gate)
|
|
671
729
|
return { content: [{ type: "text", text: gate }] };
|
|
672
730
|
const projectDirs = loadProjectDirs();
|
|
@@ -678,7 +736,7 @@ server.tool("list_projects", "Discover and analyze all projects in the workspace
|
|
|
678
736
|
// Tool: check_ports (Multi-Agent Phase 1)
|
|
679
737
|
// ---------------------------------------------------------------------------
|
|
680
738
|
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 =
|
|
739
|
+
const gate = await gateCheckFresh("check_ports");
|
|
682
740
|
if (gate)
|
|
683
741
|
return { content: [{ type: "text", text: gate }] };
|
|
684
742
|
const projectDirs = loadProjectDirs();
|
|
@@ -695,7 +753,7 @@ server.tool("run_audit", "Run the Compliance Agent audit across all projects. Ch
|
|
|
695
753
|
.default("all")
|
|
696
754
|
.describe("Audit scope: all checks, compliance only, version checks only, or port conflicts only"),
|
|
697
755
|
}, async ({ scope }) => {
|
|
698
|
-
const gate =
|
|
756
|
+
const gate = await gateCheckFresh("run_audit");
|
|
699
757
|
if (gate)
|
|
700
758
|
return { content: [{ type: "text", text: gate }] };
|
|
701
759
|
const projectDirs = loadProjectDirs();
|
|
@@ -712,7 +770,7 @@ server.tool("score_project", "Score one or all projects on AI-readiness (0-100%)
|
|
|
712
770
|
.optional()
|
|
713
771
|
.describe("Project name OR absolute directory path to score. Omit to score all projects."),
|
|
714
772
|
}, async ({ project }) => {
|
|
715
|
-
const gate =
|
|
773
|
+
const gate = await gateCheckFresh("score_project");
|
|
716
774
|
if (gate)
|
|
717
775
|
return { content: [{ type: "text", text: gate }] };
|
|
718
776
|
// 🔒 LOCKED [SCORE-CANARY-COVERS-EVERY-SCORER] — 2026-08-19
|
|
@@ -1169,7 +1227,8 @@ server.tool("list_learnings", "List all permanent learnings, optionally filtered
|
|
|
1169
1227
|
}
|
|
1170
1228
|
const learnings = listLearnings(category, activeProjectNames);
|
|
1171
1229
|
const text = formatLearnings(learnings, { since: sinceDate, sinceSpec: since });
|
|
1172
|
-
|
|
1230
|
+
// Redacted like every chunk: a learning can quote a command with its password. [LOCK] [INDEX-NEVER-SERVES-A-CREDENTIAL]
|
|
1231
|
+
return respond("list_learnings", redactChunk({ content: text }).content);
|
|
1173
1232
|
});
|
|
1174
1233
|
// ---------------------------------------------------------------------------
|
|
1175
1234
|
// Tool: delete_learning (Permanent Learning Store)
|
|
@@ -1265,6 +1324,7 @@ server.tool("activation_status", "Check current ContextEngine license status, pl
|
|
|
1265
1324
|
`- **Expires**: ${status.expiresAt}`,
|
|
1266
1325
|
`- **Delta version**: ${status.deltaVersion}`,
|
|
1267
1326
|
`- **Machine ID**: ${status.machineId}`,
|
|
1327
|
+
`- **Licence check**: ${licenceCheckState()}`, // [LOCK] [LICENSE-IS-CHECKED-DAILY]
|
|
1268
1328
|
``,
|
|
1269
1329
|
];
|
|
1270
1330
|
if (status.premiumTools.length > 0) {
|
|
@@ -1319,6 +1379,9 @@ function registerResources() {
|
|
|
1319
1379
|
// Start
|
|
1320
1380
|
// ---------------------------------------------------------------------------
|
|
1321
1381
|
async function main() {
|
|
1382
|
+
// [LOCK] [CE-HOME-IS-PRIVATE]: the folder is 0700 before the registry, the index or the log write
|
|
1383
|
+
// into it.
|
|
1384
|
+
secureCeHome();
|
|
1322
1385
|
// 0. Inventory this server FIRST, before indexing takes minutes: a server exists the moment it
|
|
1323
1386
|
// starts. [LOCK] [SERVERS-ARE-INVENTORIED]. With the shared index on, the registry is also
|
|
1324
1387
|
// the electorate: the record carries the corpus and the role. [LOCK] [ONE-INDEXER-MANY-READERS]
|
|
@@ -1331,8 +1394,9 @@ async function main() {
|
|
|
1331
1394
|
}
|
|
1332
1395
|
}
|
|
1333
1396
|
try {
|
|
1334
|
-
const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined });
|
|
1397
|
+
const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined, daemon: process.env.OPSCONTEXT_DAEMON === "1" });
|
|
1335
1398
|
setRegistryRole = reg.setRole;
|
|
1399
|
+
setRegistryEventPort = reg.setEventPort;
|
|
1336
1400
|
const fleet = listServers();
|
|
1337
1401
|
if (corpus) {
|
|
1338
1402
|
const e = electIndexer(corpus, fleet.servers, process.pid);
|
|
@@ -1397,7 +1461,7 @@ async function main() {
|
|
|
1397
1461
|
const runAutoRotate = () => {
|
|
1398
1462
|
try {
|
|
1399
1463
|
const o = autoRotateAuditLog();
|
|
1400
|
-
if (o.action === "rotated" || o.action === "refused" || o.action === "error" || o.action === "in_progress") {
|
|
1464
|
+
if (o.action === "rotated" || o.action === "refused" || o.action === "error" || o.action === "in_progress" || o.action === "finished") {
|
|
1401
1465
|
console.error(`[ContextEngine] 📦 audit auto-rotate (${o.action}): ${o.detail}`);
|
|
1402
1466
|
}
|
|
1403
1467
|
}
|
|
@@ -1466,7 +1530,13 @@ async function main() {
|
|
|
1466
1530
|
// No-op if secret is missing; the endpoint will refuse with 401 until
|
|
1467
1531
|
// a secret is configured. Failure to bind (port collision) logs and
|
|
1468
1532
|
// continues — the MCP server stays usable without browser capture.
|
|
1469
|
-
|
|
1533
|
+
// [LOCK] [EVENT-PORT-BELONGS-TO-THE-DAEMON]: the launchd agent keeps trying until it holds the
|
|
1534
|
+
// port; a chat server takes it only while no agent is alive, and hands it over when one is.
|
|
1535
|
+
startEventIngestServer({
|
|
1536
|
+
daemon: process.env.OPSCONTEXT_DAEMON === "1",
|
|
1537
|
+
onPortChange: (port) => setRegistryEventPort?.(port),
|
|
1538
|
+
liveDaemon: () => liveDaemonPid(),
|
|
1539
|
+
}).catch((err) => {
|
|
1470
1540
|
console.error("[ContextEngine] event-ingest start failed:", err);
|
|
1471
1541
|
});
|
|
1472
1542
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { existsSync, writeFileSync, mkdirSync } from "fs";
|
|
18
18
|
import { join, dirname } from "path";
|
|
19
19
|
import { homedir, platform } from "os";
|
|
20
|
-
import {
|
|
20
|
+
import { execFileSync } from "child_process";
|
|
21
21
|
import { createRequire } from "module";
|
|
22
22
|
import { fileURLToPath } from "url";
|
|
23
23
|
// 🔒 LOCKED [M2-ESM-FILENAME-FIX] — 2026-06-24
|
|
@@ -52,7 +52,7 @@ function detectNodePath() {
|
|
|
52
52
|
function detectOpscontextEntry() {
|
|
53
53
|
// (1) Try global install via `npm root -g`
|
|
54
54
|
try {
|
|
55
|
-
const globalRoot =
|
|
55
|
+
const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
56
56
|
const candidate = join(globalRoot, "@compr", "opscontext-mcp", "dist", "index.js");
|
|
57
57
|
if (existsSync(candidate))
|
|
58
58
|
return { kind: "global", path: candidate };
|
|
@@ -99,6 +99,17 @@ function detectOpscontextEntry() {
|
|
|
99
99
|
// FIX: Standard priority; the shared-index flag; CONTEXTENGINE_CONFIG passed through from the
|
|
100
100
|
// installing shell so its corpus id equals the chats'; the memory skip only if the installer
|
|
101
101
|
// was itself run with it (an explicit choice, not a default).
|
|
102
|
+
// [LOCKED] [AUTOSTART-ARGV-AND-XML-ESCAPED] - 2026-09-25
|
|
103
|
+
// [NEVER] paste a path into a launchctl/lsof/curl command string, or into the plist unescaped.
|
|
104
|
+
// WHY: with a home folder named "John Smith", `launchctl bootstrap gui/501 ${PLIST_FILE}` handed
|
|
105
|
+
// launchctl the path in two pieces and failed, after `bootout` had already stopped the running
|
|
106
|
+
// agent; a home named "R&D home" produced a plist launchd refuses ("unknown ampersand-escape
|
|
107
|
+
// sequence"); only the passthrough env values were escaped (E2E_REVIEW_2026-09 A3-2, A3-3).
|
|
108
|
+
// FIX: execFileSync with an argument list for every command; xml() on every value in the plist;
|
|
109
|
+
// `plutil -lint` on the written file before anything running is stopped.
|
|
110
|
+
function xml(value) {
|
|
111
|
+
return value.replace(/&/g, "&").replace(/</g, "<");
|
|
112
|
+
}
|
|
102
113
|
export function buildPlist(nodePath, entryPath, nodeBinDir, env = process.env) {
|
|
103
114
|
// OPSCONTEXT_DAEMON tells the server it has no MCP client on stdin and must stay alive on its
|
|
104
115
|
// own: as a reader it holds no file watchers, and every poller is unref'd, so without this the
|
|
@@ -110,7 +121,8 @@ export function buildPlist(nodePath, entryPath, nodeBinDir, env = process.env) {
|
|
|
110
121
|
passthrough.push(["CONTEXTENGINE_WORKSPACES", env.CONTEXTENGINE_WORKSPACES]);
|
|
111
122
|
if (env.OPSCONTEXT_SKIP_CLAUDE_MEMORY === "1")
|
|
112
123
|
passthrough.push(["OPSCONTEXT_SKIP_CLAUDE_MEMORY", "1"]);
|
|
113
|
-
|
|
124
|
+
// [LOCK] [AUTOSTART-ARGV-AND-XML-ESCAPED]: every value, not only the passthrough ones.
|
|
125
|
+
const extraEnv = passthrough.map(([k, v]) => ` <key>${k}</key>\n <string>${xml(v)}</string>`).join("\n");
|
|
114
126
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
115
127
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
116
128
|
<plist version="1.0">
|
|
@@ -120,21 +132,21 @@ export function buildPlist(nodePath, entryPath, nodeBinDir, env = process.env) {
|
|
|
120
132
|
|
|
121
133
|
<key>ProgramArguments</key>
|
|
122
134
|
<array>
|
|
123
|
-
<string>${nodePath}</string>
|
|
124
|
-
<string>${entryPath}</string>
|
|
135
|
+
<string>${xml(nodePath)}</string>
|
|
136
|
+
<string>${xml(entryPath)}</string>
|
|
125
137
|
</array>
|
|
126
138
|
|
|
127
139
|
<key>EnvironmentVariables</key>
|
|
128
140
|
<dict>
|
|
129
141
|
<key>PATH</key>
|
|
130
|
-
<string>${nodeBinDir}:/usr/local/bin:/usr/bin:/bin</string>
|
|
142
|
+
<string>${xml(nodeBinDir)}:/usr/local/bin:/usr/bin:/bin</string>
|
|
131
143
|
<key>HOME</key>
|
|
132
|
-
<string>${homedir()}</string>
|
|
144
|
+
<string>${xml(homedir())}</string>
|
|
133
145
|
${extraEnv}
|
|
134
146
|
</dict>
|
|
135
147
|
|
|
136
148
|
<key>WorkingDirectory</key>
|
|
137
|
-
<string>${homedir()}</string>
|
|
149
|
+
<string>${xml(homedir())}</string>
|
|
138
150
|
|
|
139
151
|
<key>RunAtLoad</key>
|
|
140
152
|
<true/>
|
|
@@ -146,10 +158,10 @@ ${extraEnv}
|
|
|
146
158
|
<integer>10</integer>
|
|
147
159
|
|
|
148
160
|
<key>StandardOutPath</key>
|
|
149
|
-
<string>${join(LOG_DIR, "mcp-stdout.log")}</string>
|
|
161
|
+
<string>${xml(join(LOG_DIR, "mcp-stdout.log"))}</string>
|
|
150
162
|
|
|
151
163
|
<key>StandardErrorPath</key>
|
|
152
|
-
<string>${join(LOG_DIR, "mcp-stderr.log")}</string>
|
|
164
|
+
<string>${xml(join(LOG_DIR, "mcp-stderr.log"))}</string>
|
|
153
165
|
|
|
154
166
|
<key>ProcessType</key>
|
|
155
167
|
<string>Standard</string>
|
|
@@ -165,7 +177,7 @@ function userId() {
|
|
|
165
177
|
}
|
|
166
178
|
function portIsOurs() {
|
|
167
179
|
try {
|
|
168
|
-
|
|
180
|
+
execFileSync("lsof", ["-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN"], { stdio: "ignore" });
|
|
169
181
|
return true;
|
|
170
182
|
}
|
|
171
183
|
catch {
|
|
@@ -177,7 +189,7 @@ function waitForPort(timeoutSec = 30) {
|
|
|
177
189
|
while (Date.now() - start < timeoutSec * 1000) {
|
|
178
190
|
if (portIsOurs())
|
|
179
191
|
return true;
|
|
180
|
-
|
|
192
|
+
execFileSync("sleep", ["1"]);
|
|
181
193
|
}
|
|
182
194
|
return false;
|
|
183
195
|
}
|
|
@@ -263,16 +275,25 @@ To view server logs: tail -f ~/.contextengine/logs/mcp-stderr.log
|
|
|
263
275
|
if (portIsOurs()) {
|
|
264
276
|
console.log(` detected existing process on :${PORT} — relying on launchctl bootout to clean it.`);
|
|
265
277
|
}
|
|
278
|
+
// [LOCK] [AUTOSTART-ARGV-AND-XML-ESCAPED]: a file launchd would refuse must never cost the user
|
|
279
|
+
// the agent that is running now, so check it before the bootout.
|
|
280
|
+
try {
|
|
281
|
+
execFileSync("plutil", ["-lint", PLIST_FILE], { stdio: ["ignore", "ignore", "pipe"] });
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
console.error(`❌ The new plist is not valid, nothing was stopped or loaded: ${err instanceof Error ? err.message : String(err)}`);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
266
287
|
// Idempotent bootstrap: bootout (ignore failure) → bootstrap
|
|
267
288
|
const uid = userId();
|
|
268
289
|
try {
|
|
269
|
-
|
|
290
|
+
execFileSync("launchctl", ["bootout", `gui/${uid}/${LABEL}`], { stdio: "ignore" });
|
|
270
291
|
}
|
|
271
292
|
catch {
|
|
272
293
|
/* not loaded — fine */
|
|
273
294
|
}
|
|
274
295
|
try {
|
|
275
|
-
|
|
296
|
+
execFileSync("launchctl", ["bootstrap", `gui/${uid}`, PLIST_FILE], { stdio: "inherit" });
|
|
276
297
|
}
|
|
277
298
|
catch (err) {
|
|
278
299
|
console.error(`❌ launchctl bootstrap failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -309,7 +330,7 @@ You can re-install with: opscontext install-autostart`);
|
|
|
309
330
|
const uid = userId();
|
|
310
331
|
let removed = false;
|
|
311
332
|
try {
|
|
312
|
-
|
|
333
|
+
execFileSync("launchctl", ["bootout", `gui/${uid}/${LABEL}`], { stdio: "ignore" });
|
|
313
334
|
removed = true;
|
|
314
335
|
}
|
|
315
336
|
catch {
|
|
@@ -350,7 +371,7 @@ running entrypoint.`);
|
|
|
350
371
|
const uid = userId();
|
|
351
372
|
let launchctlState = "not loaded";
|
|
352
373
|
try {
|
|
353
|
-
const out =
|
|
374
|
+
const out = execFileSync("launchctl", ["print", `gui/${uid}/${LABEL}`], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
354
375
|
const match = out.match(/state\s*=\s*(\S+)/);
|
|
355
376
|
if (match)
|
|
356
377
|
launchctlState = match[1];
|
|
@@ -365,7 +386,7 @@ running entrypoint.`);
|
|
|
365
386
|
console.log(` port ${PORT}: ${portUp ? "✅ listening" : "❌ not listening"}`);
|
|
366
387
|
if (portUp) {
|
|
367
388
|
try {
|
|
368
|
-
const health =
|
|
389
|
+
const health = execFileSync("curl", ["-sf", `http://127.0.0.1:${PORT}/health`], { encoding: "utf-8", timeout: 2000 });
|
|
369
390
|
console.log(` health: ${health.trim()}`);
|
|
370
391
|
}
|
|
371
392
|
catch {
|
|
@@ -13,6 +13,7 @@ export interface Settings {
|
|
|
13
13
|
}
|
|
14
14
|
/** The script path of a hook command, with $HOME, ${HOME} or a leading ~ expanded. */
|
|
15
15
|
export declare function hookScriptPath(command: string, home?: string): string;
|
|
16
|
+
export declare function shellQuote(value: string): string;
|
|
16
17
|
/** Removes repeated registrations of our scripts under the same matcher. Keeps the first copy
|
|
17
18
|
* and every hook that is not ours; drops an entry only when that leaves it empty. */
|
|
18
19
|
export declare function dropDuplicateHooks(entries: HookEntry[], ourScripts: string[], home?: string): {
|