context-mode 1.0.125 → 1.0.127

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.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/build/adapters/claude-code/hooks.d.ts +10 -4
  6. package/build/adapters/claude-code/hooks.js +22 -12
  7. package/build/adapters/claude-code/index.d.ts +24 -1
  8. package/build/adapters/claude-code/index.js +67 -11
  9. package/build/adapters/types.d.ts +57 -0
  10. package/build/adapters/types.js +29 -0
  11. package/build/cli.js +38 -13
  12. package/build/server.js +7 -0
  13. package/build/util/hook-config.d.ts +24 -1
  14. package/build/util/hook-config.js +39 -2
  15. package/build/util/plugin-cache-integrity.d.ts +37 -0
  16. package/build/util/plugin-cache-integrity.js +105 -0
  17. package/build/util/project-dir.d.ts +13 -0
  18. package/build/util/project-dir.js +11 -2
  19. package/cli.bundle.mjs +122 -122
  20. package/hooks/core/routing.mjs +114 -22
  21. package/hooks/gemini-cli/sessionstart.mjs +8 -6
  22. package/hooks/security.bundle.mjs +1 -0
  23. package/hooks/sessionstart.mjs +18 -0
  24. package/openclaw.plugin.json +1 -1
  25. package/package.json +4 -3
  26. package/scripts/plugin-cache-integrity.mjs +248 -0
  27. package/server.bundle.mjs +94 -94
  28. package/start.mjs +37 -0
  29. package/skills/UPSTREAM-CREDITS.md +0 -51
  30. package/skills/diagnose/SKILL.md +0 -122
  31. package/skills/diagnose/scripts/hitl-loop.template.sh +0 -41
  32. package/skills/grill-me/SKILL.md +0 -15
  33. package/skills/grill-with-docs/ADR-FORMAT.md +0 -47
  34. package/skills/grill-with-docs/CONTEXT-FORMAT.md +0 -77
  35. package/skills/grill-with-docs/SKILL.md +0 -93
  36. package/skills/improve-codebase-architecture/DEEPENING.md +0 -37
  37. package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +0 -44
  38. package/skills/improve-codebase-architecture/LANGUAGE.md +0 -53
  39. package/skills/improve-codebase-architecture/SKILL.md +0 -76
  40. package/skills/tdd/SKILL.md +0 -114
  41. package/skills/tdd/deep-modules.md +0 -33
  42. package/skills/tdd/interface-design.md +0 -31
  43. package/skills/tdd/mocking.md +0 -59
  44. package/skills/tdd/refactoring.md +0 -10
  45. package/skills/tdd/tests.md +0 -61
@@ -249,39 +249,88 @@ let securityInitFailed = false;
249
249
  /**
250
250
  * @returns {boolean} true if security module loaded successfully.
251
251
  *
252
- * Loud fail: if `build/security.js` is missing or fails to import, log a
253
- * clear stderr warning instead of swallowing the error silently. Without
254
- * this, user-configured `permissions.deny` patterns (#466) become no-ops
255
- * with no indication that policy enforcement is disabled — a fail-open
256
- * security regression.
252
+ * Loud fail: if neither the esbuild bundle nor `build/security.js` is
253
+ * importable, log a clear stderr warning instead of swallowing the error
254
+ * silently. Without this, user-configured `permissions.deny` patterns
255
+ * (#466) become no-ops with no indication that policy enforcement is
256
+ * disabled — a fail-open security regression.
257
+ *
258
+ * ─── Resolution order (#558) ───────────────────────────────────────────
259
+ *
260
+ * 1. `hooks/security.bundle.mjs` — esbuild output, sibling of routing.mjs's
261
+ * parent. Marketplace installs (`git clone` install path) ship this
262
+ * bundle via CI's `git add -f`, so it's the only artifact reliably
263
+ * present across BOTH `npm install` (build/ generated by tsc) AND
264
+ * marketplace install (build/ excluded by .gitignore, never built).
265
+ *
266
+ * 2. `<buildDir>/security.js` — tsc output. Present after `npm run build`.
267
+ * Kept as a fallback so source checkouts that bypass `npm run bundle`
268
+ * still degrade gracefully to the tsc-emitted module.
269
+ *
270
+ * Bundle path is computed from `import.meta.url` (sibling layout:
271
+ * `hooks/core/routing.mjs` → `hooks/security.bundle.mjs`).
272
+ * `CONTEXT_MODE_SECURITY_BUNDLE_PATH` is a test seam — it lets
273
+ * subprocess-based tests stage a bundle in tmpdir without polluting the
274
+ * repo's hooks/ directory.
257
275
  */
258
276
  export async function initSecurity(buildDir) {
259
- try {
260
- const { existsSync } = await import("node:fs");
261
- const { resolve } = await import("node:path");
262
- const { pathToFileURL } = await import("node:url");
263
- const secPath = resolve(buildDir, "security.js");
264
- if (!existsSync(secPath)) {
277
+ const { existsSync } = await import("node:fs");
278
+ const { resolve, dirname } = await import("node:path");
279
+ const { fileURLToPath, pathToFileURL } = await import("node:url");
280
+
281
+ // Default: <hooks/core/ dir>/../security.bundle.mjs → hooks/security.bundle.mjs.
282
+ const defaultBundlePath = resolve(
283
+ dirname(fileURLToPath(import.meta.url)),
284
+ "..",
285
+ "security.bundle.mjs",
286
+ );
287
+ const bundlePath = process.env.CONTEXT_MODE_SECURITY_BUNDLE_PATH || defaultBundlePath;
288
+ const secPath = resolve(buildDir, "security.js");
289
+
290
+ // Bundle-first: marketplace installs ship the bundle, never the build/ dir.
291
+ if (existsSync(bundlePath)) {
292
+ try {
293
+ security = await import(pathToFileURL(bundlePath).href);
294
+ return true;
295
+ } catch (err) {
265
296
  if (!securityInitFailed && !process.env.CONTEXT_MODE_SUPPRESS_SECURITY_WARNING) {
266
297
  process.stderr.write(
267
- `[context-mode] WARNING: ${secPath} not found security deny patterns will NOT be enforced. ` +
268
- `Run \`npm run build\` to generate it. Set CONTEXT_MODE_SUPPRESS_SECURITY_WARNING=1 to silence.\n`,
298
+ `[context-mode] WARNING: failed to load security bundle (${bundlePath}) — deny patterns NOT enforced: ${err?.message ?? err}\n`,
269
299
  );
270
300
  }
271
301
  securityInitFailed = true;
272
302
  return false;
273
303
  }
274
- security = await import(pathToFileURL(secPath).href);
275
- return true;
276
- } catch (err) {
277
- if (!securityInitFailed && !process.env.CONTEXT_MODE_SUPPRESS_SECURITY_WARNING) {
278
- process.stderr.write(
279
- `[context-mode] WARNING: failed to load security module deny patterns NOT enforced: ${err?.message ?? err}\n`,
280
- );
304
+ }
305
+
306
+ // Fallback: tsc-emitted build/security.js (source checkout + `npm run build`).
307
+ if (existsSync(secPath)) {
308
+ try {
309
+ security = await import(pathToFileURL(secPath).href);
310
+ return true;
311
+ } catch (err) {
312
+ if (!securityInitFailed && !process.env.CONTEXT_MODE_SUPPRESS_SECURITY_WARNING) {
313
+ process.stderr.write(
314
+ `[context-mode] WARNING: failed to load security module — deny patterns NOT enforced: ${err?.message ?? err}\n`,
315
+ );
316
+ }
317
+ securityInitFailed = true;
318
+ return false;
281
319
  }
282
- securityInitFailed = true;
283
- return false;
284
320
  }
321
+
322
+ // Neither artifact present — preserve fail-open with an actionable warning
323
+ // that mentions BOTH paths so users on either install model can self-diagnose.
324
+ if (!securityInitFailed && !process.env.CONTEXT_MODE_SUPPRESS_SECURITY_WARNING) {
325
+ process.stderr.write(
326
+ `[context-mode] WARNING: security module not found — security deny patterns will NOT be enforced.\n` +
327
+ ` Searched: ${bundlePath} (bundle) and ${secPath} (build).\n` +
328
+ ` Marketplace installs ship hooks/security.bundle.mjs via CI; for source checkouts run \`npm run bundle\` (or \`npm run build\`).\n` +
329
+ ` Set CONTEXT_MODE_SUPPRESS_SECURITY_WARNING=1 to silence.\n`,
330
+ );
331
+ }
332
+ securityInitFailed = true;
333
+ return false;
285
334
  }
286
335
 
287
336
  /** @returns {boolean} true if a previous initSecurity() call failed to load the module. */
@@ -289,6 +338,49 @@ export function isSecurityInitFailed() {
289
338
  return securityInitFailed;
290
339
  }
291
340
 
341
+ /**
342
+ * Build the agent-facing additionalContext block surfacing the security
343
+ * init failure (#558).
344
+ *
345
+ * Pre-558 the only signal of a fail-open security regression was a
346
+ * stderr WARNING line that adapters typically suppress / discard. The
347
+ * user had no in-band signal that `permissions.deny` was no-op'd.
348
+ *
349
+ * Returns a structured XML-ish block when initSecurity() has failed,
350
+ * `null` otherwise. SessionStart hooks append the block to their
351
+ * additionalContext so the agent (and through the agent, the user)
352
+ * sees the warning the next time they view the session — not just in
353
+ * suppressed stderr.
354
+ *
355
+ * The block format intentionally mirrors the `<context_guidance>`
356
+ * shape used elsewhere in routing so existing prompt-template
357
+ * scaffolding picks it up without special-casing.
358
+ */
359
+ export function buildSecurityWarningContext() {
360
+ if (!securityInitFailed) return null;
361
+ return [
362
+ "<context_mode_security_warning>",
363
+ " <severity>HIGH</severity>",
364
+ " <issue>",
365
+ " The context-mode security module failed to load.",
366
+ " User-configured `permissions.deny` patterns are NOT being enforced.",
367
+ " Bash commands and file operations bypass the deny gate (fail-open).",
368
+ " </issue>",
369
+ " <root_cause>",
370
+ " `hooks/security.bundle.mjs` (and `build/security.js`) are absent or unloadable.",
371
+ " Common on marketplace installs where `build/` is gitignored and the",
372
+ " bundle was missing prior to v1.0.127.",
373
+ " </root_cause>",
374
+ " <fix>",
375
+ " Run `npm run bundle` from the context-mode source checkout, OR",
376
+ " upgrade context-mode to v1.0.127+ (which ships hooks/security.bundle.mjs",
377
+ " via CI). To opt in to fail-CLOSED instead, set CONTEXT_MODE_REQUIRE_SECURITY=1.",
378
+ " To silence this warning while you investigate, set CONTEXT_MODE_SUPPRESS_SECURITY_WARNING=1.",
379
+ " </fix>",
380
+ "</context_mode_security_warning>",
381
+ ].join("\n");
382
+ }
383
+
292
384
  /**
293
385
  * Normalize platform-specific tool names to canonical (Claude Code) names.
294
386
  *
@@ -25,7 +25,7 @@ import { createSessionLoaders } from "../session-loaders.mjs";
25
25
  import { join, dirname } from "node:path";
26
26
  import { readFileSync, unlinkSync } from "node:fs";
27
27
  import { homedir } from "node:os";
28
- import { fileURLToPath, pathToFileURL } from "node:url";
28
+ import { fileURLToPath } from "node:url";
29
29
 
30
30
  const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
31
31
  const { loadSessionDB } = createSessionLoaders(HOOK_DIR);
@@ -88,11 +88,13 @@ try {
88
88
  const sessionId = getSessionId(input, OPTS);
89
89
  db.ensureSession(sessionId, projectDir);
90
90
 
91
- // Auto-write GEMINI.md on startup if missing or not merged yet
92
- try {
93
- const { GeminiCLIAdapter } = await import(pathToFileURL(join(HOOK_DIR, "..", "..", "build", "adapters", "gemini-cli", "index.js")).href);
94
- new GeminiCLIAdapter().writeRoutingInstructions(projectDir, join(HOOK_DIR, "..", ".."));
95
- } catch { /* best effort don't block session start */ }
91
+ // NOTE (#558): excised the old GEMINI.md auto-write block. It loaded an
92
+ // adapter from build/ (gitignored, missing on marketplace installs) and
93
+ // called a method that was deleted from every adapter in commit 6dae20c.
94
+ // Both layers were silently no-op'd by the surrounding try/catch on every
95
+ // install path for many releases. If routing-instruction auto-write is
96
+ // reintroduced it must come with its own PRD, method spec, and format
97
+ // tests — out of scope for the security regression fix.
96
98
 
97
99
  const ruleFilePaths = [
98
100
  join(homedir(), ".gemini", "GEMINI.md"),
@@ -0,0 +1 @@
1
+ import{readFileSync as S,realpathSync as R}from"node:fs";import{resolve as u}from"node:path";import{resolve as a}from"node:path";import{homedir as d}from"node:os";import{createRequire as C}from"node:module";function v(e=process.env){let s=e.CLAUDE_CONFIG_DIR;return s&&s.trim()!==""?s.startsWith("~")?a(d(),s.replace(/^~[/\\]?/,"")):a(s):a(d(),".claude")}function E(e=process.env){return a(v(e),"settings.json")}function h(e=process.env){let s=[],t=null,n=null;try{let r=C(import.meta.url)("../adapters/detect.js");t=r.detectPlatform(),n=r.getSessionDirSegments}catch{}if(t&&n&&t.platform!=="claude-code"){let o=n(t.platform);o&&o.length>0&&s.push(a(d(),...o,"settings.json"))}let i=E(e);return s.includes(i)||s.push(i),s}function w(e){let s=e.match(/^Bash\((.+)\)$/);return s?s[1]:null}function k(e){let s=e.match(/^(\w+)\((.+)\)$/);return s?{tool:s[1],glob:s[2]}:null}function $(e){return e.replace(/[.*+?^${}()|[\]\\\/\-]/g,"\\$&")}function P(e){return e.replace(/[.+?^${}()|[\]\\\/\-]/g,"\\$&").replace(/\*/g,".*")}function A(e,s=!1){let t,n=e.indexOf(":");if(n!==-1){let i=e.slice(0,n),o=e.slice(n+1),r=$(i),l=P(o);t=`^${r}(\\s${l})?$`}else t=`^${P(e)}$`;return new RegExp(t,s?"i":"")}function G(e,s=!1){let t="",n=0;for(;n<e.length;)e[n]==="*"&&e[n+1]==="*"?n+2<e.length&&e[n+2]==="/"?(t+="(.*/)?",n+=3):(t+=".*",n+=2):e[n]==="*"?(t+="[^/]*",n++):e[n]==="?"?(t+="[^/]",n++):(t+=e[n].replace(/[.+^${}()|[\]\\\/\-]/g,"\\$&"),n++);return new RegExp(`^${t}$`,s?"i":"")}function f(e,s,t=!1){for(let n of s){let i=w(n);if(i&&A(i,t).test(e))return n}return null}function b(e){let s=[],t="",n=!1,i=!1,o=!1;for(let r=0;r<e.length;r++){let l=e[r],c=r>0?e[r-1]:"";l==="'"&&!i&&!o&&c!=="\\"?(n=!n,t+=l):l==='"'&&!n&&!o&&c!=="\\"?(i=!i,t+=l):l==="`"&&!n&&!i&&c!=="\\"?(o=!o,t+=l):!n&&!i&&!o?l===";"?(s.push(t.trim()),t=""):l==="|"&&e[r+1]==="|"||l==="&"&&e[r+1]==="&"?(s.push(t.trim()),t="",r++):l==="|"?(s.push(t.trim()),t=""):t+=l:t+=l}return t.trim()&&s.push(t.trim()),s.filter(r=>r.length>0)}function m(e){let s;try{s=S(e,"utf-8")}catch{return null}let t;try{t=JSON.parse(s)}catch{return null}let n=t?.permissions;if(!n||typeof n!="object")return null;let i=o=>Array.isArray(o)?o.filter(r=>typeof r=="string"&&w(r)!==null):[];return{allow:i(n.allow),deny:i(n.deny),ask:i(n.ask)}}function L(e,s){let t=[];if(e){let i=u(e,".claude","settings.local.json"),o=m(i);o&&t.push(o);let r=u(e,".claude","settings.json"),l=m(r);l&&t.push(l)}let n=s!==void 0?[s]:h();for(let i of n){let o=m(i);o&&t.push(o)}return t}function M(e,s,t){let n=[],i=r=>{let l;try{l=S(r,"utf-8")}catch{return null}let c;try{c=JSON.parse(l)}catch{return null}let g=c?.permissions?.deny;if(!Array.isArray(g))return[];let y=[];for(let x of g){if(typeof x!="string")continue;let p=k(x);p&&p.tool===e&&y.push(p.glob)}return y};if(s){let r=i(u(s,".claude","settings.local.json"));r!==null&&n.push(r);let l=i(u(s,".claude","settings.json"));l!==null&&n.push(l)}let o=t!==void 0?[t]:h();for(let r of o){let l=i(r);l!==null&&n.push(l)}return n}function q(e,s,t=process.platform==="win32"){let n=b(e);for(let i of n)for(let o of s){let r=f(i,o.deny,t);if(r)return{decision:"deny",matchedPattern:r}}for(let i of s){let o=f(e,i.ask,t);if(o)return{decision:"ask",matchedPattern:o};let r=f(e,i.allow,t);if(r)return{decision:"allow",matchedPattern:r}}return{decision:"ask"}}function z(e,s,t=process.platform==="win32"){let n=b(e);for(let i of n)for(let o of s){let r=f(i,o.deny,t);if(r)return{decision:"deny",matchedPattern:r}}return{decision:"allow"}}function I(e,s,t=process.platform==="win32",n){let i=r=>r.replace(/\\/g,"/"),o=new Set;if(o.add(i(e)),n){let r=u(n,e);o.add(i(r));try{o.add(i(R(r)))}catch{}}for(let r of s)for(let l of r){let c=G(i(l),t);for(let g of o)if(c.test(g))return{denied:!0,matchedPattern:l}}return{denied:!1}}var _={python:[/os\.system\(\s*(['"])(.*?)\1\s*\)/g,/subprocess\.(?:run|call|Popen|check_output|check_call)\(\s*(['"])(.*?)\1/g],javascript:[/exec(?:Sync|File|FileSync)?\(\s*(['"`])(.*?)\1/g,/spawn(?:Sync)?\(\s*(['"`])(.*?)\1/g],typescript:[/exec(?:Sync|File|FileSync)?\(\s*(['"`])(.*?)\1/g,/spawn(?:Sync)?\(\s*(['"`])(.*?)\1/g],ruby:[/system\(\s*(['"])(.*?)\1/g,/`(.*?)`/g],go:[/exec\.Command\(\s*(['"`])(.*?)\1/g],php:[/shell_exec\(\s*(['"`])(.*?)\1/g,/(?:^|[^.])exec\(\s*(['"`])(.*?)\1/g,/(?:^|[^.])system\(\s*(['"`])(.*?)\1/g,/passthru\(\s*(['"`])(.*?)\1/g,/proc_open\(\s*(['"`])(.*?)\1/g],rust:[/Command::new\(\s*(['"`])(.*?)\1/g]};function F(e){let s=[],t=/subprocess\.(?:run|call|Popen|check_output|check_call)\(\s*\[([^\]]+)\]/g,n;for(;(n=t.exec(e))!==null;){let o=[...n[1].matchAll(/(['"])(.*?)\1/g)].map(r=>r[2]);o.length>0&&s.push(o.join(" "))}return s}function H(e,s){let t=_[s];if(!t&&s!=="python")return[];let n=[];if(t)for(let i of t){i.lastIndex=0;let o;for(;(o=i.exec(e))!==null;){let r=o[o.length-1];r&&n.push(r)}}return s==="python"&&n.push(...F(e)),n}export{q as evaluateCommand,z as evaluateCommandDenyOnly,I as evaluateFilePath,H as extractShellCommands,G as fileGlobToRegex,A as globToRegex,f as matchesAnyPattern,w as parseBashPattern,k as parseToolPattern,L as readBashPolicies,M as readToolDenyPatterns,b as splitChainedCommands};
@@ -53,6 +53,24 @@ await runHook(async () => {
53
53
 
54
54
  let additionalContext = ROUTING_BLOCK;
55
55
 
56
+ // ─── #558: surface security init failure as agent-facing context ───
57
+ //
58
+ // Pre-558 the only signal of a fail-open security regression was a
59
+ // stderr WARNING line (suppressed/discarded by most adapters). The
60
+ // SessionStart additionalContext block is the in-band channel — the
61
+ // agent reads it, the user sees it. Idempotent by virtue of
62
+ // SessionStart's once-per-session lifecycle.
63
+ try {
64
+ const { initSecurity, isSecurityInitFailed, buildSecurityWarningContext } =
65
+ await import("./core/routing.mjs");
66
+ const { resolve: _resolve } = await import("node:path");
67
+ await initSecurity(_resolve(HOOK_DIR, "..", "build"));
68
+ if (isSecurityInitFailed()) {
69
+ const warning = buildSecurityWarningContext();
70
+ if (warning) additionalContext = warning + "\n\n" + additionalContext;
71
+ }
72
+ } catch { /* security probe is best-effort — never block session start */ }
73
+
56
74
  try {
57
75
  const raw = await readStdin();
58
76
  const input = parseStdin(raw);
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.125",
6
+ "version": "1.0.127",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.125",
3
+ "version": "1.0.127",
4
4
  "type": "module",
5
5
  "description": "MCP plugin that saves 98% of your context window. Works with Claude Code, Gemini CLI, VS Code Copilot, OpenCode, and Codex CLI. Sandboxed code execution, FTS5 knowledge base, and intent-driven search.",
6
6
  "author": "Mert Koseoğlu",
@@ -79,14 +79,15 @@
79
79
  "scripts/postinstall.mjs",
80
80
  "scripts/heal-better-sqlite3.mjs",
81
81
  "scripts/heal-installed-plugins.mjs",
82
+ "scripts/plugin-cache-integrity.mjs",
82
83
  "README.md",
83
84
  "LICENSE"
84
85
  ],
85
86
  "scripts": {
86
87
  "build": "tsc && node -e \"if(process.platform!=='win32'){require('fs').chmodSync('build/cli.js',0o755)}\" && npm run bundle && npm run assert-bundle && npm run assert-asymmetric-drift",
87
- "assert-bundle": "node scripts/assert-bundle.mjs server.bundle.mjs cli.bundle.mjs hooks/session-extract.bundle.mjs hooks/session-snapshot.bundle.mjs hooks/session-db.bundle.mjs",
88
+ "assert-bundle": "node scripts/assert-bundle.mjs server.bundle.mjs cli.bundle.mjs hooks/session-extract.bundle.mjs hooks/session-snapshot.bundle.mjs hooks/session-db.bundle.mjs hooks/security.bundle.mjs",
88
89
  "assert-asymmetric-drift": "node scripts/assert-asymmetric-drift.mjs",
89
- "bundle": "esbuild src/server.ts --bundle --platform=node --target=node18 --format=esm --outfile=server.bundle.mjs --external:better-sqlite3 --external:turndown --external:turndown-plugin-gfm --external:@mixmark-io/domino --minify && esbuild src/cli.ts --bundle --platform=node --target=node18 --format=esm --outfile=cli.bundle.mjs --external:better-sqlite3 --minify && esbuild src/session/extract.ts --bundle --platform=node --target=node18 --format=esm --outfile=hooks/session-extract.bundle.mjs --minify && esbuild src/session/snapshot.ts --bundle --platform=node --target=node18 --format=esm --outfile=hooks/session-snapshot.bundle.mjs --minify && esbuild src/session/db.ts --bundle --platform=node --target=node18 --format=esm --outfile=hooks/session-db.bundle.mjs --external:better-sqlite3 --minify",
90
+ "bundle": "esbuild src/server.ts --bundle --platform=node --target=node18 --format=esm --outfile=server.bundle.mjs --external:better-sqlite3 --external:turndown --external:turndown-plugin-gfm --external:@mixmark-io/domino --minify && esbuild src/cli.ts --bundle --platform=node --target=node18 --format=esm --outfile=cli.bundle.mjs --external:better-sqlite3 --minify && esbuild src/session/extract.ts --bundle --platform=node --target=node18 --format=esm --outfile=hooks/session-extract.bundle.mjs --minify && esbuild src/session/snapshot.ts --bundle --platform=node --target=node18 --format=esm --outfile=hooks/session-snapshot.bundle.mjs --minify && esbuild src/session/db.ts --bundle --platform=node --target=node18 --format=esm --outfile=hooks/session-db.bundle.mjs --external:better-sqlite3 --minify && esbuild src/security.ts --bundle --platform=node --target=node18 --format=esm --outfile=hooks/security.bundle.mjs --minify",
90
91
  "version-sync": "node scripts/version-sync.mjs",
91
92
  "version": "node scripts/version-sync.mjs && git add package.json .claude-plugin/plugin.json .claude-plugin/marketplace.json .cursor-plugin/plugin.json .codex-plugin/plugin.json .openclaw-plugin/openclaw.plugin.json .openclaw-plugin/package.json openclaw.plugin.json .pi/extensions/context-mode/package.json",
92
93
  "prepublishOnly": "npm run build",
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Plugin cache integrity check (Algo-D4 + Algo-D5).
3
+ *
4
+ * Algorithmic defense against #550: a partial install (interrupted npm
5
+ * install, broken marketplace pull, half-finished /ctx-upgrade) leaves
6
+ * start.mjs spawnable but a critical sibling (server.bundle.mjs,
7
+ * cli.bundle.mjs, hooks/<event>.mjs, …) missing. The MCP child then
8
+ * dies silently downstream and the user sees an opaque "MCP server
9
+ * failed to start" with no actionable signal.
10
+ *
11
+ * The expected sibling tree is DERIVED from `package.json files[]` —
12
+ * the npm publish source of truth. Adding a new entry there auto-
13
+ * extends the integrity check; no parallel hardcoded list to maintain
14
+ * (the trap that bites every project that hand-rolls "list of files
15
+ * that must exist at runtime").
16
+ *
17
+ * Two consumers:
18
+ * 1. start.mjs at boot — calls assertPluginCacheIntegrity, on !ok
19
+ * writes a structured CONTEXT_MODE_PARTIAL_INSTALL stderr block
20
+ * and exits 2. Fail-fast — the alternative is a downstream stack
21
+ * trace from `import("./server.bundle.mjs")` that hides the
22
+ * actual root cause.
23
+ * 2. src/cli.ts ctx doctor (Algo-D5) — same helper, same answer,
24
+ * surfaced as a HealthCheck so users get the diagnostic without
25
+ * restarting the MCP server.
26
+ *
27
+ * Pure JS, Node.js built-ins only. Ships in package.json files[] so
28
+ * users running off the npm tarball get the same code path the
29
+ * developer ran during `pretest`.
30
+ */
31
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
32
+ import { join, relative, sep } from "node:path";
33
+
34
+ /**
35
+ * Walk a directory recursively, returning a flat list of relative file
36
+ * paths (using `/` as separator inside the returned strings). Skips
37
+ * unreadable entries silently — the integrity check operates on what
38
+ * IS readable; missing entries are reported by the caller.
39
+ */
40
+ function listFilesRecursive(absDir, baseAbs) {
41
+ const out = [];
42
+ let entries;
43
+ try {
44
+ entries = readdirSync(absDir);
45
+ } catch {
46
+ return out; // unreadable — caller will report the parent as missing
47
+ }
48
+ for (const name of entries) {
49
+ const full = join(absDir, name);
50
+ let st;
51
+ try {
52
+ st = statSync(full);
53
+ } catch {
54
+ continue;
55
+ }
56
+ if (st.isDirectory()) {
57
+ out.push(...listFilesRecursive(full, baseAbs));
58
+ } else {
59
+ out.push(relative(baseAbs, full));
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Compute the expected sibling tree for a given pluginRoot, derived
67
+ * from the supplied `package.json files[]` array.
68
+ *
69
+ * Algorithm:
70
+ * - Each entry in files[] is resolved against pluginRoot.
71
+ * - If it points to a directory → list every file inside recursively.
72
+ * - If it points to a file → kept as-is.
73
+ * - Entries that don't exist at probe-time are EXCLUDED from the
74
+ * manifest (they show up as `missing` in the assert step instead).
75
+ * This avoids the trap of "manifest contains paths that have never
76
+ * existed" — the manifest is a snapshot of WHAT IS, not WHAT WAS
77
+ * PUBLISHED.
78
+ *
79
+ * Returns relative paths (relative to pluginRoot). Used by both
80
+ * assertPluginCacheIntegrity and the doctor surface.
81
+ */
82
+ export function derivePluginManifest({ pkg, pluginRoot }) {
83
+ if (!pkg || !Array.isArray(pkg.files)) return [];
84
+ const manifest = new Set();
85
+ for (const entry of pkg.files) {
86
+ if (typeof entry !== "string" || !entry) continue;
87
+ const absEntry = join(pluginRoot, entry);
88
+ if (!existsSync(absEntry)) continue;
89
+ let st;
90
+ try {
91
+ st = statSync(absEntry);
92
+ } catch {
93
+ continue;
94
+ }
95
+ if (st.isDirectory()) {
96
+ for (const f of listFilesRecursive(absEntry, pluginRoot)) manifest.add(f);
97
+ } else {
98
+ manifest.add(entry);
99
+ }
100
+ }
101
+ return [...manifest];
102
+ }
103
+
104
+ /**
105
+ * LEGACY_FALLBACK — the v1.0.126 hardcoded REQUIRED_RUNTIME_SIBLINGS,
106
+ * preserved verbatim. Forms the union seed for the algorithmic set so
107
+ * the post-558 contract is strictly additive over the pre-558 contract
108
+ * (no required sibling ever silently disappears).
109
+ *
110
+ * Also acts as a safety net when `package.json` is unreadable — the
111
+ * boot gate stays loud even if the publish manifest is corrupted.
112
+ */
113
+ const LEGACY_FALLBACK = Object.freeze([
114
+ "server.bundle.mjs",
115
+ "cli.bundle.mjs",
116
+ join("hooks", "pretooluse.mjs"),
117
+ join("hooks", "posttooluse.mjs"),
118
+ join("hooks", "precompact.mjs"),
119
+ join("hooks", "sessionstart.mjs"),
120
+ join("hooks", "userpromptsubmit.mjs"),
121
+ ]);
122
+
123
+ /**
124
+ * SOFT_FALLBACK_BUNDLES — bundles that already implement
125
+ * bundle-first / build-fallback resolution (via session-loaders.mjs or
126
+ * session-helpers.mjs). Their absence on a published install is
127
+ * gracefully recoverable, so they MUST NOT join the fail-fast boot
128
+ * gate — the gate would refuse to start a working install.
129
+ *
130
+ * The security bundle is intentionally NOT here: its absence creates a
131
+ * silent fail-OPEN regression (#558), so it IS boot-critical.
132
+ */
133
+ const SOFT_FALLBACK_BUNDLES = new Set([
134
+ "hooks/session-extract.bundle.mjs",
135
+ "hooks/session-snapshot.bundle.mjs",
136
+ "hooks/session-db.bundle.mjs",
137
+ "hooks/session-attribution.bundle.mjs",
138
+ ]);
139
+
140
+ /**
141
+ * Algorithmically extract every esbuild output path from
142
+ * `package.json scripts.bundle`. The bundle script is the SINGLE
143
+ * SOURCE OF TRUTH for "what bundles this build produces" — parsing
144
+ * its `--outfile=…` arguments avoids the parallel-list trap that
145
+ * bit Algo-D4 v1.0.126 (the hardcoded REQUIRED list lagged the
146
+ * actual bundle output).
147
+ *
148
+ * Returns POSIX-style relative paths (forward slashes) for stable
149
+ * comparison with SOFT_FALLBACK_BUNDLES. Caller normalizes to
150
+ * `path.join` shape before pluginRoot-relative resolution.
151
+ */
152
+ function extractBundleOutfiles(pkg) {
153
+ const script = pkg?.scripts?.bundle;
154
+ if (typeof script !== "string") return [];
155
+ const out = new Set();
156
+ // Match every `--outfile=<path>` token (path is whitespace-delimited
157
+ // because the script chains commands with `&&`).
158
+ const re = /--outfile=(\S+)/g;
159
+ let m;
160
+ while ((m = re.exec(script)) !== null) {
161
+ out.add(m[1]);
162
+ }
163
+ return [...out];
164
+ }
165
+
166
+ /**
167
+ * Algorithmic — derive the boot-critical sibling set as the union of:
168
+ * 1. LEGACY_FALLBACK (the v1.0.126 contract, preserved verbatim).
169
+ * 2. Every esbuild output path from `package.json scripts.bundle`
170
+ * that is NOT in SOFT_FALLBACK_BUNDLES.
171
+ *
172
+ * Why algorithmic instead of hardcoded:
173
+ *
174
+ * v1.0.126 shipped Algo-D4 with a hardcoded REQUIRED_RUNTIME_SIBLINGS
175
+ * array that omitted `hooks/security.bundle.mjs` (the bundle didn't
176
+ * ship until v1.0.127). The hardcoded list would need manual
177
+ * extension every time a runtime bundle is added — the same trap
178
+ * would re-bite the next bundle. Deriving from `scripts.bundle`
179
+ * closes the trap: any new bundle output is auto-gated unless it
180
+ * joins the soft-fallback whitelist (which is itself an explicit
181
+ * architectural decision, not a maintenance burden). (#558)
182
+ *
183
+ * Returns OS-native-separator relative paths (suitable for
184
+ * `path.join(pluginRoot, …)`).
185
+ *
186
+ * If `package.json` is unreadable, returns LEGACY_FALLBACK as a
187
+ * safety net so the boot gate never goes silent due to a parse
188
+ * error in the publish manifest.
189
+ */
190
+ export function getRequiredRuntimeSiblings(pluginRoot) {
191
+ let pkg;
192
+ try {
193
+ pkg = JSON.parse(readFileSync(join(pluginRoot, "package.json"), "utf-8"));
194
+ } catch {
195
+ return [...LEGACY_FALLBACK];
196
+ }
197
+ const required = new Set(LEGACY_FALLBACK);
198
+ for (const outfile of extractBundleOutfiles(pkg)) {
199
+ // Normalize to POSIX for soft-fallback membership check —
200
+ // scripts.bundle is hand-authored with forward slashes already,
201
+ // but be defensive in case a Windows-authored package.json ever
202
+ // reaches us.
203
+ const posix = outfile.split(sep).join("/");
204
+ if (SOFT_FALLBACK_BUNDLES.has(posix)) continue;
205
+ // Convert back to OS-native sep for downstream filesystem ops.
206
+ required.add(posix.split("/").join(sep));
207
+ }
208
+ return [...required];
209
+ }
210
+
211
+ /**
212
+ * Verify boot-critical siblings exist at pluginRoot.
213
+ *
214
+ * Returns `{ ok, missing }`. Pure — does NOT touch process.exit or
215
+ * stderr. The caller (start.mjs at boot, src/cli.ts at doctor) decides
216
+ * the failure surface (fail-fast exit 2 vs. doctor diagnostic).
217
+ *
218
+ * Required-set is computed by `getRequiredRuntimeSiblings()` —
219
+ * algorithmically derived from `package.json files[]` filtered to the
220
+ * RUNTIME_CRITICAL_PATTERN. Drift between publish manifest and runtime
221
+ * contract becomes architecturally impossible (#558).
222
+ */
223
+ export function assertPluginCacheIntegrity({ pluginRoot }) {
224
+ const missing = [];
225
+ for (const rel of getRequiredRuntimeSiblings(pluginRoot)) {
226
+ const abs = join(pluginRoot, rel);
227
+ if (!existsSync(abs)) missing.push(abs);
228
+ }
229
+ return { ok: missing.length === 0, missing };
230
+ }
231
+
232
+ /**
233
+ * Format the structured stderr block start.mjs emits when integrity
234
+ * fails. Marker line `CONTEXT_MODE_PARTIAL_INSTALL` lets external
235
+ * monitoring grep for the exact failure mode without parsing free-form
236
+ * text. Keep the format stable across versions.
237
+ */
238
+ export function formatPartialInstallReport({ pluginRoot, missing }) {
239
+ const lines = [
240
+ "CONTEXT_MODE_PARTIAL_INSTALL",
241
+ ` pluginRoot: ${pluginRoot}`,
242
+ " missing:",
243
+ ...missing.map((m) => ` - ${m}`),
244
+ " fix: rm -rf the install dir and re-pull (marketplace) or run `npm install -g context-mode` again.",
245
+ "",
246
+ ];
247
+ return lines.join("\n");
248
+ }