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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/build/adapters/claude-code/hooks.d.ts +10 -4
- package/build/adapters/claude-code/hooks.js +22 -12
- package/build/adapters/claude-code/index.d.ts +24 -1
- package/build/adapters/claude-code/index.js +67 -11
- package/build/adapters/types.d.ts +57 -0
- package/build/adapters/types.js +29 -0
- package/build/cli.js +38 -13
- package/build/server.js +7 -0
- package/build/util/hook-config.d.ts +24 -1
- package/build/util/hook-config.js +39 -2
- package/build/util/plugin-cache-integrity.d.ts +37 -0
- package/build/util/plugin-cache-integrity.js +105 -0
- package/build/util/project-dir.d.ts +13 -0
- package/build/util/project-dir.js +11 -2
- package/cli.bundle.mjs +122 -122
- package/hooks/core/routing.mjs +114 -22
- package/hooks/gemini-cli/sessionstart.mjs +8 -6
- package/hooks/security.bundle.mjs +1 -0
- package/hooks/sessionstart.mjs +18 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -3
- package/scripts/plugin-cache-integrity.mjs +248 -0
- package/server.bundle.mjs +94 -94
- package/start.mjs +37 -0
- package/skills/UPSTREAM-CREDITS.md +0 -51
- package/skills/diagnose/SKILL.md +0 -122
- package/skills/diagnose/scripts/hitl-loop.template.sh +0 -41
- package/skills/grill-me/SKILL.md +0 -15
- package/skills/grill-with-docs/ADR-FORMAT.md +0 -47
- package/skills/grill-with-docs/CONTEXT-FORMAT.md +0 -77
- package/skills/grill-with-docs/SKILL.md +0 -93
- package/skills/improve-codebase-architecture/DEEPENING.md +0 -37
- package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +0 -44
- package/skills/improve-codebase-architecture/LANGUAGE.md +0 -53
- package/skills/improve-codebase-architecture/SKILL.md +0 -76
- package/skills/tdd/SKILL.md +0 -114
- package/skills/tdd/deep-modules.md +0 -33
- package/skills/tdd/interface-design.md +0 -31
- package/skills/tdd/mocking.md +0 -59
- package/skills/tdd/refactoring.md +0 -10
- package/skills/tdd/tests.md +0 -61
package/hooks/core/routing.mjs
CHANGED
|
@@ -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
|
|
253
|
-
* clear stderr warning instead of swallowing the error
|
|
254
|
-
* this, user-configured `permissions.deny` patterns
|
|
255
|
-
* with no indication that policy enforcement is
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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: ${
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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};
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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);
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|