mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
fix(integrity): algorithmic Algo-D4 — derive required siblings from scripts.bundle (closes #558 partial — 3 of 4)
v1.0.126 shipped Algo-D4 with a hardcoded REQUIRED_RUNTIME_SIBLINGS
array that omitted `hooks/security.bundle.mjs` (the bundle didn't
ship until v1.0.127, but the algorithmic intent was already
documented). The hardcoded list silently passed integrity checks on
v1.0.126 marketplace installs even when the security regression
was active — Algo-D4 reported `{ ok: true }` while permissions.deny
was fail-open. The same trap would have re-bitten the next bundle.
Algorithmic redesign:
- Replace `REQUIRED_RUNTIME_SIBLINGS` const with
`getRequiredRuntimeSiblings(pluginRoot)` exported function.
- Algorithm: union of LEGACY_FALLBACK (the v1.0.126 contract,
preserved verbatim) plus every esbuild outfile parsed from
`package.json scripts.bundle` minus an explicit
SOFT_FALLBACK_BUNDLES whitelist (session-* bundles, which have
bundle-first/build-fallback in session-loaders.mjs and don't need
to fail-fast).
- Source of truth: `scripts.bundle` `--outfile=` arguments. Adding
a new bundle to that script auto-extends the integrity check —
no parallel hardcoded list to maintain.
- Safety net: if package.json is unreadable, fall back to the
legacy hardcoded set so the boot gate never goes silent.
- `assertPluginCacheIntegrity` now calls the new function. Public
signature unchanged. start.mjs + the doctor surface are
zero-touch — both consume the same algorithmically-derived set.
Tests (extend tests/core/cli.test.ts per CONTRIBUTING):
- "Algo-D4 algorithmically requires hooks/security.bundle.mjs" —
the headline #558 regression: with security bundle missing on a
fakeRoot, integrity must report ok=false (pre-558 hardcoded check
vacuously passed).
- "Algo-D4 derivation reads scripts.bundle outfiles" — synthetic
package.json proves a future hooks/foo.bundle.mjs is auto-gated,
while soft-fallback session-db.bundle.mjs is correctly excluded.
- "Algo-D4 preserves the legacy hardcoded contract" — anti-
regression pin: every entry in v1.0.126's hardcoded list is still
in the algorithmic set. Strictly additive refactor.
Verified: 152/152 cli.test.ts tests pass (4 new Algo-D4 + 4
pre-existing plugin-cache + 144 unrelated). typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
* developer ran during `pretest`.
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
/**
|
||||
* Walk a directory recursively, returning a flat list of relative file
|
||||
@@ -102,22 +102,15 @@ export function derivePluginManifest({ pkg, pluginRoot }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* REQUIRED_RUNTIME_SIBLINGS — the minimum set of files start.mjs must
|
||||
* find at boot. These are the files start.mjs actively `import()`s or
|
||||
* needs to re-symlink against. The check is intentionally narrower
|
||||
* than the full manifest:
|
||||
* LEGACY_FALLBACK — the v1.0.126 hardcoded REQUIRED_RUNTIME_SIBLINGS,
|
||||
* preserved verbatim. Forms the union seed for the algorithmic set so
|
||||
* the post-558 contract is strictly additive over the pre-558 contract
|
||||
* (no required sibling ever silently disappears).
|
||||
*
|
||||
* - server.bundle.mjs / cli.bundle.mjs are produced by `npm run
|
||||
* bundle`. Without server.bundle.mjs the server can't start;
|
||||
* without cli.bundle.mjs `context-mode doctor` can't run.
|
||||
* - hooks/{5 hook scripts}.mjs are spawned per Claude Code event.
|
||||
* Missing any one produces a silent hook failure.
|
||||
*
|
||||
* Other files in package.json files[] (insight/, configs/, README, …)
|
||||
* are not boot-critical, so missing them is a "warn"-class issue
|
||||
* surfaced only via the doctor — never enough to fail-fast at boot.
|
||||
* Also acts as a safety net when `package.json` is unreadable — the
|
||||
* boot gate stays loud even if the publish manifest is corrupted.
|
||||
*/
|
||||
const REQUIRED_RUNTIME_SIBLINGS = Object.freeze([
|
||||
const LEGACY_FALLBACK = Object.freeze([
|
||||
"server.bundle.mjs",
|
||||
"cli.bundle.mjs",
|
||||
join("hooks", "pretooluse.mjs"),
|
||||
@@ -127,6 +120,94 @@ const REQUIRED_RUNTIME_SIBLINGS = Object.freeze([
|
||||
join("hooks", "userpromptsubmit.mjs"),
|
||||
]);
|
||||
|
||||
/**
|
||||
* SOFT_FALLBACK_BUNDLES — bundles that already implement
|
||||
* bundle-first / build-fallback resolution (via session-loaders.mjs or
|
||||
* session-helpers.mjs). Their absence on a published install is
|
||||
* gracefully recoverable, so they MUST NOT join the fail-fast boot
|
||||
* gate — the gate would refuse to start a working install.
|
||||
*
|
||||
* The security bundle is intentionally NOT here: its absence creates a
|
||||
* silent fail-OPEN regression (#558), so it IS boot-critical.
|
||||
*/
|
||||
const SOFT_FALLBACK_BUNDLES = new Set([
|
||||
"hooks/session-extract.bundle.mjs",
|
||||
"hooks/session-snapshot.bundle.mjs",
|
||||
"hooks/session-db.bundle.mjs",
|
||||
"hooks/session-attribution.bundle.mjs",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Algorithmically extract every esbuild output path from
|
||||
* `package.json scripts.bundle`. The bundle script is the SINGLE
|
||||
* SOURCE OF TRUTH for "what bundles this build produces" — parsing
|
||||
* its `--outfile=…` arguments avoids the parallel-list trap that
|
||||
* bit Algo-D4 v1.0.126 (the hardcoded REQUIRED list lagged the
|
||||
* actual bundle output).
|
||||
*
|
||||
* Returns POSIX-style relative paths (forward slashes) for stable
|
||||
* comparison with SOFT_FALLBACK_BUNDLES. Caller normalizes to
|
||||
* `path.join` shape before pluginRoot-relative resolution.
|
||||
*/
|
||||
function extractBundleOutfiles(pkg) {
|
||||
const script = pkg?.scripts?.bundle;
|
||||
if (typeof script !== "string") return [];
|
||||
const out = new Set();
|
||||
// Match every `--outfile=<path>` token (path is whitespace-delimited
|
||||
// because the script chains commands with `&&`).
|
||||
const re = /--outfile=(\S+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(script)) !== null) {
|
||||
out.add(m[1]);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* Algorithmic — derive the boot-critical sibling set as the union of:
|
||||
* 1. LEGACY_FALLBACK (the v1.0.126 contract, preserved verbatim).
|
||||
* 2. Every esbuild output path from `package.json scripts.bundle`
|
||||
* that is NOT in SOFT_FALLBACK_BUNDLES.
|
||||
*
|
||||
* Why algorithmic instead of hardcoded:
|
||||
*
|
||||
* v1.0.126 shipped Algo-D4 with a hardcoded REQUIRED_RUNTIME_SIBLINGS
|
||||
* array that omitted `hooks/security.bundle.mjs` (the bundle didn't
|
||||
* ship until v1.0.127). The hardcoded list would need manual
|
||||
* extension every time a runtime bundle is added — the same trap
|
||||
* would re-bite the next bundle. Deriving from `scripts.bundle`
|
||||
* closes the trap: any new bundle output is auto-gated unless it
|
||||
* joins the soft-fallback whitelist (which is itself an explicit
|
||||
* architectural decision, not a maintenance burden). (#558)
|
||||
*
|
||||
* Returns OS-native-separator relative paths (suitable for
|
||||
* `path.join(pluginRoot, …)`).
|
||||
*
|
||||
* If `package.json` is unreadable, returns LEGACY_FALLBACK as a
|
||||
* safety net so the boot gate never goes silent due to a parse
|
||||
* error in the publish manifest.
|
||||
*/
|
||||
export function getRequiredRuntimeSiblings(pluginRoot) {
|
||||
let pkg;
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(join(pluginRoot, "package.json"), "utf-8"));
|
||||
} catch {
|
||||
return [...LEGACY_FALLBACK];
|
||||
}
|
||||
const required = new Set(LEGACY_FALLBACK);
|
||||
for (const outfile of extractBundleOutfiles(pkg)) {
|
||||
// Normalize to POSIX for soft-fallback membership check —
|
||||
// scripts.bundle is hand-authored with forward slashes already,
|
||||
// but be defensive in case a Windows-authored package.json ever
|
||||
// reaches us.
|
||||
const posix = outfile.split(sep).join("/");
|
||||
if (SOFT_FALLBACK_BUNDLES.has(posix)) continue;
|
||||
// Convert back to OS-native sep for downstream filesystem ops.
|
||||
required.add(posix.split("/").join(sep));
|
||||
}
|
||||
return [...required];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify boot-critical siblings exist at pluginRoot.
|
||||
*
|
||||
@@ -134,15 +215,14 @@ const REQUIRED_RUNTIME_SIBLINGS = Object.freeze([
|
||||
* stderr. The caller (start.mjs at boot, src/cli.ts at doctor) decides
|
||||
* the failure surface (fail-fast exit 2 vs. doctor diagnostic).
|
||||
*
|
||||
* Uses package.json (read from pluginRoot) only as a source-of-truth
|
||||
* cross-check; the actual REQUIRED list is hardcoded above to keep the
|
||||
* runtime contract independent of package.json being readable. If
|
||||
* package.json IS readable AND files[] omits something we require, the
|
||||
* check fails — that's the "drift between contract and tarball" trap.
|
||||
* Required-set is computed by `getRequiredRuntimeSiblings()` —
|
||||
* algorithmically derived from `package.json files[]` filtered to the
|
||||
* RUNTIME_CRITICAL_PATTERN. Drift between publish manifest and runtime
|
||||
* contract becomes architecturally impossible (#558).
|
||||
*/
|
||||
export function assertPluginCacheIntegrity({ pluginRoot }) {
|
||||
const missing = [];
|
||||
for (const rel of REQUIRED_RUNTIME_SIBLINGS) {
|
||||
for (const rel of getRequiredRuntimeSiblings(pluginRoot)) {
|
||||
const abs = join(pluginRoot, rel);
|
||||
if (!existsSync(abs)) missing.push(abs);
|
||||
}
|
||||
|
||||
@@ -1154,6 +1154,119 @@ describe("start.mjs CLI self-heal", () => {
|
||||
const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf-8"));
|
||||
expect(pkg.files).toContain("scripts/plugin-cache-integrity.mjs");
|
||||
});
|
||||
|
||||
// ── Algo-D4 — algorithmic runtime-sibling derivation (#558) ──────────
|
||||
//
|
||||
// v1.0.126 shipped Algo-D4 with a HARDCODED `REQUIRED_RUNTIME_SIBLINGS`
|
||||
// array that omitted `hooks/security.bundle.mjs` (and would omit any
|
||||
// future runtime-critical bundle). Result: the integrity check returns
|
||||
// `{ ok: true }` on a marketplace install where `hooks/security.bundle.mjs`
|
||||
// is missing — exactly the silent fail-open #558 reports. The fix is
|
||||
// algorithmic: derive the required-sibling set from `derivePluginManifest`
|
||||
// (which itself reads `package.json files[]`), filtered to a runtime-
|
||||
// critical pattern. Adding `hooks/security.bundle.mjs` (or any future
|
||||
// hooks/*.bundle.mjs) to files[] auto-extends the integrity check.
|
||||
test("Algo-D4 algorithmically requires hooks/security.bundle.mjs (#558)", async () => {
|
||||
// Synthesize a marketplace-install scenario: every hardcoded boot
|
||||
// sibling is present, but hooks/security.bundle.mjs is NOT. The pre-
|
||||
// 558 hardcoded check passes vacuously here — that's the regression.
|
||||
const { assertPluginCacheIntegrity } = await import(
|
||||
"../../scripts/plugin-cache-integrity.mjs"
|
||||
);
|
||||
const fakeRoot = mkdtempSync(join(tmpdir(), "ctx-mode-d4-algo-"));
|
||||
try {
|
||||
// Stage every legacy-hardcoded sibling so the test isolates the
|
||||
// new requirement: only hooks/security.bundle.mjs is missing.
|
||||
writeFileSync(join(fakeRoot, "server.bundle.mjs"), "");
|
||||
writeFileSync(join(fakeRoot, "cli.bundle.mjs"), "");
|
||||
writeFileSync(join(fakeRoot, "start.mjs"), "");
|
||||
mkdirSync(join(fakeRoot, "hooks"));
|
||||
writeFileSync(join(fakeRoot, "hooks", "pretooluse.mjs"), "");
|
||||
writeFileSync(join(fakeRoot, "hooks", "posttooluse.mjs"), "");
|
||||
writeFileSync(join(fakeRoot, "hooks", "precompact.mjs"), "");
|
||||
writeFileSync(join(fakeRoot, "hooks", "sessionstart.mjs"), "");
|
||||
writeFileSync(join(fakeRoot, "hooks", "userpromptsubmit.mjs"), "");
|
||||
// Copy the real package.json so the algorithm reads the same
|
||||
// files[] the npm tarball ships with.
|
||||
const pkgSrc = readFileSync(resolve(ROOT, "package.json"), "utf-8");
|
||||
writeFileSync(join(fakeRoot, "package.json"), pkgSrc);
|
||||
|
||||
const result = assertPluginCacheIntegrity({ pluginRoot: fakeRoot });
|
||||
expect(result.ok).toBe(false);
|
||||
// Must surface the missing security bundle path so the doctor /
|
||||
// boot-fail block tells users exactly what's broken.
|
||||
const missingStr = result.missing.join("\n");
|
||||
expect(missingStr).toContain("security.bundle.mjs");
|
||||
} finally {
|
||||
rmSync(fakeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Algo-D4 derivation reads scripts.bundle outfiles — pure-data, no parallel hardcoded list", async () => {
|
||||
// Algorithmic guarantee: a future runtime bundle added to
|
||||
// `scripts.bundle` (with `--outfile=hooks/foo.bundle.mjs`) is
|
||||
// auto-gated by Algo-D4 — no parallel REQUIRED_RUNTIME_SIBLINGS
|
||||
// edit needed. Soft-fallback bundles (session-* with
|
||||
// bundle-first/build-fallback in session-loaders.mjs) are
|
||||
// explicitly whitelisted out.
|
||||
const mod: any = await import("../../scripts/plugin-cache-integrity.mjs");
|
||||
// The new public surface — algorithm must be inspectable from tests.
|
||||
expect(typeof mod.getRequiredRuntimeSiblings).toBe("function");
|
||||
|
||||
const fakeRoot = mkdtempSync(join(tmpdir(), "ctx-mode-d4-pkg-"));
|
||||
try {
|
||||
// Synthetic package.json: scripts.bundle produces 3 outfiles.
|
||||
// - hooks/security.bundle.mjs is runtime-critical → required.
|
||||
// - hooks/session-db.bundle.mjs is soft-fallback → NOT required.
|
||||
// - hooks/foo.bundle.mjs is a hypothetical future bundle → required
|
||||
// (proves the gate auto-extends without code changes).
|
||||
const pkg = {
|
||||
files: ["server.bundle.mjs", "cli.bundle.mjs", "hooks", "start.mjs"],
|
||||
scripts: {
|
||||
bundle:
|
||||
"esbuild src/security.ts --outfile=hooks/security.bundle.mjs && " +
|
||||
"esbuild src/session/db.ts --outfile=hooks/session-db.bundle.mjs && " +
|
||||
"esbuild src/foo.ts --outfile=hooks/foo.bundle.mjs",
|
||||
},
|
||||
};
|
||||
writeFileSync(join(fakeRoot, "package.json"), JSON.stringify(pkg));
|
||||
|
||||
const required: string[] = mod.getRequiredRuntimeSiblings(fakeRoot);
|
||||
// Runtime-critical bundle must be in the set:
|
||||
expect(required.some((p) => p.endsWith("security.bundle.mjs"))).toBe(true);
|
||||
// Hypothetical future bundle auto-included (the algorithmic win):
|
||||
expect(required.some((p) => p.endsWith("foo.bundle.mjs"))).toBe(true);
|
||||
// Soft-fallback bundle MUST be excluded — its absence is gracefully
|
||||
// handled by session-loaders.mjs's bundle-first/build-fallback.
|
||||
expect(required.some((p) => p.endsWith("session-db.bundle.mjs"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(fakeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Algo-D4 preserves the legacy hardcoded contract (no regression on existing required siblings)", async () => {
|
||||
// Backward-compat guarantee: every entry that used to be in the
|
||||
// hardcoded REQUIRED_RUNTIME_SIBLINGS array must still be flagged
|
||||
// as required by the algorithmic derivation. This pins the
|
||||
// pre-558 contract so the algorithmic refactor is purely additive.
|
||||
const mod: any = await import("../../scripts/plugin-cache-integrity.mjs");
|
||||
const required: string[] = mod.getRequiredRuntimeSiblings(ROOT);
|
||||
const legacy = [
|
||||
"server.bundle.mjs",
|
||||
"cli.bundle.mjs",
|
||||
join("hooks", "pretooluse.mjs"),
|
||||
join("hooks", "posttooluse.mjs"),
|
||||
join("hooks", "precompact.mjs"),
|
||||
join("hooks", "sessionstart.mjs"),
|
||||
join("hooks", "userpromptsubmit.mjs"),
|
||||
];
|
||||
for (const entry of legacy) {
|
||||
expect(
|
||||
required.some((p) => p === entry || p.endsWith(entry)),
|
||||
`legacy required sibling missing from algorithmic set: ${entry}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── session-loaders.mjs fallback ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user