@compr/opscontext-mcp 2.10.0 → 2.12.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.
@@ -50,13 +50,16 @@ export const SECRET_SHAPES = [
50
50
  { id: "api_key_header", re: /(\bx-api-key:\s*)[^\s'"]{8,}/gi, keepPrefix: true },
51
51
  // name = value, the long tail. Skips variables, env lookups, paths, placeholders already
52
52
  // redacted, type names and function calls.
53
- // A name that is PASS alone or ends in a separator plus PASS (SMTP_PASS, DB_PASS, db.pass) counts
54
- // too: a real mail password sat in the shared index under SMTP_PASS (E2E_REVIEW_2026-09 A6-3).
55
- // The separator keeps bypass and compass out.
53
+ // A name that ends in a separator plus PASS (SMTP_PASS, DB_PASS, db.pass) counts too: a real mail
54
+ // password sat in the shared index under SMTP_PASS (E2E_REVIEW_2026-09 A6-3). The separator keeps
55
+ // bypass and compass out. A bare `pass` counts only before an equals sign, or before a quoted value
56
+ // after a colon (nodemailer's auth block): as first shipped in 2.10.0 it also took prose (a README's
57
+ // "PII pass" list) and code (a count of passing checks in agents.ts), which the public-release scan
58
+ // refused.
56
59
  {
57
60
  id: "credential_assignment",
58
61
  // A backtick opens a value too: "password: `...`" in Markdown, found in the real log.
59
- re: /(\b(?:[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret)|(?:[\w.-]*[_.-])?pass)["'`]?\s*[:=]\s*["'`]?)(?![$<{*/~.[]|process\.env|os\.environ|getenv)[^\s'"`,;)}\]]{6,}/gi,
62
+ re: /(\b(?:[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret)|[\w.-]*[_.-]pass|pass(?=["'`]?\s*=|["'`]?\s*:\s*["'`]))["'`]?\s*[:=]\s*["'`]?)(?![$<{*/~.[]|process\.env|os\.environ|getenv)[^\s'"`,;)}\]]{6,}/gi,
60
63
  keepPrefix: true,
61
64
  skip: (value, after) => after.startsWith("(") ||
62
65
  (/^[A-Za-z_$][\w$.]*\(/.test(value) && after.startsWith(")")) || // getToken(user), not a value
@@ -42,6 +42,8 @@ export declare const SERVER_COUNT_WARN = 3;
42
42
  */
43
43
  export declare function buildHashOf(scriptPath: string): string | null;
44
44
  export declare function isAlive(pid: number): boolean;
45
+ /** The record's own process is still running. [LOCK] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS] */
46
+ export declare function recordIsLive(rec: Pick<ServerRecord, "pid" | "started">): boolean;
45
47
  /**
46
48
  * Register the running server. Returns a stop() that removes the record; exit handlers call it too.
47
49
  */
@@ -51,6 +53,7 @@ export declare function registerServer(opts: {
51
53
  corpus?: string;
52
54
  role?: "indexer" | "reader";
53
55
  daemon?: boolean;
56
+ onSignal?: () => void;
54
57
  }): {
55
58
  record: ServerRecord;
56
59
  stop: () => void;
@@ -76,6 +76,60 @@ export function isAlive(pid) {
76
76
  return e?.code === "EPERM"; // exists, not ours
77
77
  }
78
78
  }
79
+ /**
80
+ * [LOCKED] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS] - 2026-09-27
81
+ * [NEVER] treat a registry record as a live server because its pid exists.
82
+ * WHY: pids are reused. A dead launchd agent's record pointed at a live unrelated process (3 of 3
83
+ * valid runs, E2E_REVIEW_2026-09 B5-1): for as long as that process lived, no reader took over
84
+ * indexing (a doc change never reached the index), nobody answered on the event port, a new chat
85
+ * server did not take it, and `contextengine servers` listed the unrelated process as the
86
+ * current launchd agent holding the port. Records survive a crash or a power cut, and pids wrap.
87
+ * FIX: a record is alive when its pid exists AND that process started no later than the record says
88
+ * the server did (the server registers after its process starts; a reused pid belongs to a
89
+ * process that started later). Start times come from one `ps -o pid=,lstart=` for all uncached
90
+ * pids of a listing, cached 60 s per pid. When ps cannot answer, the pid test alone decides, as
91
+ * before. A record that fails is dead: removed by the lister, never elected, never the daemon.
92
+ */
93
+ const START_CACHE_MS = 60_000;
94
+ const startCache = new Map();
95
+ function refreshStartTimes(pids) {
96
+ const now = Date.now();
97
+ const need = pids.filter((p) => { const c = startCache.get(p); return !c || now - c.at > START_CACHE_MS; });
98
+ if (need.length === 0)
99
+ return;
100
+ let out = "";
101
+ try {
102
+ // Hardcoded argv, no shell: the only variables are numbers.
103
+ out = execFileSync("ps", ["-o", "pid=,lstart=", "-p", need.join(",")], { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] });
104
+ }
105
+ catch (e) {
106
+ const stdout = e.stdout;
107
+ out = typeof stdout === "string" ? stdout : ""; // ps exits 1 when some pid is gone; keep what it printed
108
+ }
109
+ const seen = new Set();
110
+ for (const line of out.split("\n")) {
111
+ const m = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
112
+ if (!m)
113
+ continue;
114
+ const t = Date.parse(m[2]);
115
+ seen.add(Number(m[1]));
116
+ startCache.set(Number(m[1]), { startedMs: Number.isNaN(t) ? null : t, at: now });
117
+ }
118
+ for (const p of need)
119
+ if (!seen.has(p))
120
+ startCache.set(p, { startedMs: null, at: now });
121
+ }
122
+ /** The record's own process is still running. [LOCK] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS] */
123
+ export function recordIsLive(rec) {
124
+ if (!isAlive(rec.pid))
125
+ return false;
126
+ refreshStartTimes([rec.pid]);
127
+ const startedMs = startCache.get(rec.pid)?.startedMs ?? null;
128
+ const registered = Date.parse(rec.started);
129
+ if (startedMs === null || Number.isNaN(registered))
130
+ return true; // cannot tell: the pid test decides
131
+ return startedMs <= registered + 5_000; // lstart has one-second resolution
132
+ }
79
133
  /**
80
134
  * Register the running server. Returns a stop() that removes the record; exit handlers call it too.
81
135
  */
@@ -119,7 +173,11 @@ export function registerServer(opts) {
119
173
  };
120
174
  process.on("exit", stop);
121
175
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
122
- process.on(sig, () => { stop(); process.exit(0); });
176
+ // The record goes first; the caller may end the process its own way. [LOCK] [A-CHAT-SERVER-ENDS-WITH-ITS-CHAT]
177
+ process.on(sig, () => { stop(); if (opts.onSignal)
178
+ opts.onSignal();
179
+ else
180
+ process.exit(0); });
123
181
  }
124
182
  const setRole = (role) => { record.role = role; write(); };
125
183
  const setEventPort = (port) => {
@@ -141,7 +199,7 @@ export function liveDaemonPid(exceptPid = process.pid) {
141
199
  continue;
142
200
  try {
143
201
  const rec = JSON.parse(readFileSync(join(dir, f), "utf8"));
144
- if (rec.daemon && rec.pid !== exceptPid && isAlive(rec.pid))
202
+ if (rec.daemon && rec.pid !== exceptPid && recordIsLive(rec))
145
203
  return rec.pid;
146
204
  }
147
205
  catch {
@@ -156,6 +214,7 @@ export function listServers() {
156
214
  const report = { servers: [], removed: 0, warnings: [] };
157
215
  if (!existsSync(dir))
158
216
  return report;
217
+ const records = [];
159
218
  for (const f of readdirSync(dir)) {
160
219
  if (!f.endsWith(".json"))
161
220
  continue;
@@ -172,7 +231,12 @@ export function listServers() {
172
231
  report.removed++;
173
232
  continue;
174
233
  }
175
- if (!isAlive(rec.pid)) {
234
+ records.push({ path, rec });
235
+ }
236
+ refreshStartTimes(records.filter(({ rec }) => isAlive(rec.pid)).map(({ rec }) => rec.pid)); // one ps for the listing
237
+ for (const { path, rec } of records) {
238
+ // [LOCK] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS]
239
+ if (!recordIsLive(rec)) {
176
240
  try {
177
241
  unlinkSync(path);
178
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",