@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/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 { redactSecrets } from "./secret-shapes.js";
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 { gateCheck, activate, getActivationStatus, } from "./activation.js";
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
- if (config.adapters && config.adapters.length > 0) {
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
- * [LOCKED] [INDEX-NEVER-SERVES-A-CREDENTIAL] - 2026-09-25
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
- const summary = exists ? summarizeSource(s) : "";
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
- const content = readFileSync(source.path, "utf-8");
652
- return respond("read_source", `# ${source.name}\n\n${content}`, source_name);
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 = gateCheck("list_projects");
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 = gateCheck("check_ports");
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 = gateCheck("run_audit");
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 = gateCheck("score_project");
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
- return respond("list_learnings", text);
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
- startEventIngestServer().catch((err) => {
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 { execSync } from "child_process";
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 = execSync("npm root -g 2>/dev/null", { encoding: "utf-8" }).trim();
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, "&amp;").replace(/</g, "&lt;");
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
- const extraEnv = passthrough.map(([k, v]) => ` <key>${k}</key>\n <string>${v.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</string>`).join("\n");
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
- execSync(`lsof -nP -iTCP:${PORT} -sTCP:LISTEN >/dev/null 2>&1`, { stdio: "ignore" });
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
- execSync("sleep 1");
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
- execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
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
- execSync(`launchctl bootstrap gui/${uid} ${PLIST_FILE}`, { stdio: "inherit" });
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
- execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
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 = execSync(`launchctl print gui/${uid}/${LABEL} 2>/dev/null || true`, { encoding: "utf-8" });
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 = execSync(`curl -sf http://127.0.0.1:${PORT}/health`, { encoding: "utf-8", timeout: 2000 });
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): {