fix(hooks): re-point stale cache-version paths after Claude Code auto-update (#604) (#611)

Claude Code's native plugin manager auto-update copies the previous
version's *already-normalized* `hooks/hooks.json` and
`.claude-plugin/plugin.json` forward into the new version's cache dir.
The `${CLAUDE_PLUGIN_ROOT}` placeholder is gone, so the existing
`normalizeHooksJson` / `normalizePluginJson` flow short-circuits — and
the stale `…/<old-version>/hooks/<file>.mjs` command paths persist.
The old version dir was cleaned up by the plugin manager, so every hook
fires `MODULE_NOT_FOUND`, while `ctx-doctor` stays green (it only
verifies the *current* version's files exist and that hooks are
*configured* — never that command paths point at the current version).

Break the ratchet:

* New `CACHE_VERSION_RE` matches `context-mode/context-mode/<X.Y.Z>`
  segments inside arbitrary content.
* New `pluginRootVersion(pluginRoot)` extracts the current version from
  the pluginRoot. Returns null for npm-global / dev-checkout layouts —
  callers treat null as "no stale-path check possible", which is the
  correct behavior outside the Claude Code cache layout (no regression
  in those scenarios).
* New `hasStaleCacheVersionSegment(content, currentVersion)` returns
  true when content references any cache version that is not the
  current one.
* `needsHookNormalization(content, pluginRoot)` now also returns true
  for already-resolved content carrying a stale cache version. The
  second arg is optional for backwards compatibility with single-arg
  callers.
* `normalizeHooksJson` / `normalizePluginJson` per-command loops add a
  parallel branch alongside the existing placeholder branch: when a
  command (or plugin.json arg string) holds a stale version segment,
  rewrite `context-mode/context-mode/<old>` → `…/<current>` via the
  same regex. Forward-slash form so MSYS-mangled paths heal as well.
* `normalizeHooksOnStartup` passes `pluginRoot` to the detection check
  so the new path actually engages at boot.

Same class as #411 (.mcp.json) and #523 (.claude-plugin/plugin.json).
#528 fixed the `/ctx-upgrade` tmpdir variant — this covers the
previous-version-directory variant from Claude Code's auto-update.

Test plan:
* `tests/hooks/windows-hooks-normalization.test.ts` — 3 new
  regression tests under `describe("normalize-hooks survives a
  version bump (#604)")`. Each fails on `main`, all green after the
  fix:
  - `re-points an already-normalized hooks.json to the new version`
    (the failing case from the issue body verbatim)
  - `needsHookNormalization detects stale cache-root version segment`
  - `normalizeHooksOnStartup self-heals stale hooks.json on next
    boot` (end-to-end with two temp dirs mirroring the auto-update)
* `npm run typecheck` — clean.
* `npm test` — 145/145 files pass, 3302/3345 tests pass (43 skipped,
  0 fail). Same baseline as `origin/next` before this patch — no
  regression introduced.

Repro log preserved at /tmp/see-real-bug_mksglu-context-mode_604.log
(invoked via /see-real-bug).
This commit is contained in:
Ben Younes
2026-05-18 12:34:51 +03:00
committed by GitHub
parent 485570ebff
commit 97792c59d9
2 changed files with 207 additions and 19 deletions
+101 -19
View File
@@ -18,18 +18,70 @@ import { resolve } from "node:path";
const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
// #604: matches a cache path segment `context-mode/context-mode/<version>`.
// Capture group is the X.Y.Z version. Used to detect command paths frozen on a
// previous-version dir that Claude Code's native plugin manager has since
// cleaned up. `/g` so a single content blob with multiple stale references is
// fully covered. Forward-slash only — callers convert beforehand.
const CACHE_VERSION_RE =
/context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?=\/)/g;
/** Convert any path string to forward slashes (MSYS-safe). */
function fwd(p) {
return String(p).replace(/\\/g, "/");
}
/**
* Pure detection: does this content contain an unresolved CLAUDE_PLUGIN_ROOT
* placeholder that should be normalized?
* Extract the X.Y.Z version segment from a pluginRoot under the context-mode
* cache layout. Returns null when running from npm-global, a dev checkout, or
* any layout that does not match the `<…>/context-mode/context-mode/<v>(/…)?`
* pattern — callers must treat null as "no stale-path check is possible".
*/
export function needsHookNormalization(content) {
function pluginRootVersion(pluginRoot) {
if (!pluginRoot) return null;
const m =
/context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?:\/|$)/.exec(
fwd(pluginRoot),
);
return m ? m[1] : null;
}
/**
* Does `content` reference any context-mode cache version segment that differs
* from `currentVersion`? Detects the #604 ratchet: already-normalized hooks.json
* / plugin.json carrying a previous version's absolute paths forward into a
* newer version's cache directory after Claude Code's auto-update.
*/
function hasStaleCacheVersionSegment(content, currentVersion) {
if (!currentVersion || !content || typeof content !== "string") return false;
const safe = fwd(content);
CACHE_VERSION_RE.lastIndex = 0;
let m;
while ((m = CACHE_VERSION_RE.exec(safe)) !== null) {
if (m[1] !== currentVersion) return true;
}
return false;
}
/**
* Pure detection: does this content need to be (re-)normalized?
*
* Two triggers:
* 1. Fresh content still containing the `${CLAUDE_PLUGIN_ROOT}` placeholder
* — the original #378 first-boot path on any host.
* 2. (#604) Already-resolved content whose absolute paths point at a
* different version of the context-mode cache than the current
* `pluginRoot`. Breaks the ratchet that previously froze stale paths
* after Claude Code's native plugin manager copied a previous version's
* hooks.json forward.
*
* `pluginRoot` is optional for backwards compatibility with single-arg
* callers; without it, only the placeholder check runs.
*/
export function needsHookNormalization(content, pluginRoot) {
if (!content || typeof content !== "string") return false;
return content.includes(PLACEHOLDER);
if (content.includes(PLACEHOLDER)) return true;
return hasStaleCacheVersionSegment(content, pluginRootVersion(pluginRoot));
}
/**
@@ -41,10 +93,11 @@ export function needsHookNormalization(content) {
* Idempotent — leaves already-normalized content unchanged.
*/
export function normalizeHooksJson(content, nodePath, pluginRoot) {
if (!needsHookNormalization(content)) return content;
if (!needsHookNormalization(content, pluginRoot)) return content;
const safeNode = fwd(nodePath);
const safeRoot = fwd(pluginRoot);
const currentVersion = pluginRootVersion(pluginRoot);
let parsed;
try {
@@ -65,12 +118,30 @@ export function normalizeHooksJson(content, nodePath, pluginRoot) {
if (!Array.isArray(inner)) continue;
for (const h of inner) {
if (typeof h?.command !== "string") continue;
if (!h.command.includes(PLACEHOLDER)) continue;
// Replace placeholder with absolute root (forward-slash).
let next = h.command.replaceAll(PLACEHOLDER, safeRoot);
// Replace bare `node ` prefix with quoted execPath. Match both
// `node ` and `node\t` at start, with optional surrounding whitespace.
next = next.replace(/^\s*node\s+/, `"${safeNode}" `);
const hasPlaceholder = h.command.includes(PLACEHOLDER);
// #604: also rewrite when the command holds a stale absolute path under
// a previous-version cache dir (Claude Code's auto-update ratchet).
const hasStale = hasStaleCacheVersionSegment(h.command, currentVersion);
if (!hasPlaceholder && !hasStale) continue;
let next = h.command;
if (hasPlaceholder) {
// Replace placeholder with absolute root (forward-slash).
next = next.replaceAll(PLACEHOLDER, safeRoot);
// Replace bare `node ` prefix with quoted execPath. Match both
// `node ` and `node\t` at start, with optional surrounding whitespace.
next = next.replace(/^\s*node\s+/, `"${safeNode}" `);
}
if (hasStale) {
// Re-point every `context-mode/context-mode/<old-version>/…` segment
// to the current pluginRoot's version. Operates on the forward-slash
// form so MSYS-mangled paths heal as well.
next = fwd(next).replace(
CACHE_VERSION_RE,
`context-mode/context-mode/${currentVersion}`,
);
}
h.command = next;
mutated = true;
}
@@ -92,10 +163,11 @@ export function normalizeHooksJson(content, nodePath, pluginRoot) {
* Idempotent.
*/
export function normalizePluginJson(content, nodePath, pluginRoot) {
if (!needsHookNormalization(content)) return content;
if (!needsHookNormalization(content, pluginRoot)) return content;
const safeNode = fwd(nodePath);
const safeRoot = fwd(pluginRoot);
const currentVersion = pluginRootVersion(pluginRoot);
let parsed;
try {
@@ -114,11 +186,21 @@ export function normalizePluginJson(content, nodePath, pluginRoot) {
if (Array.isArray(srv.args)) {
const before = srv.args;
const after = before.map((a) =>
typeof a === "string" && a.includes(PLACEHOLDER)
? a.replaceAll(PLACEHOLDER, safeRoot)
: a,
);
const after = before.map((a) => {
if (typeof a !== "string") return a;
let next = a;
if (next.includes(PLACEHOLDER)) {
next = next.replaceAll(PLACEHOLDER, safeRoot);
}
// #604: same auto-update ratchet hits plugin.json args (see #523).
if (hasStaleCacheVersionSegment(next, currentVersion)) {
next = fwd(next).replace(
CACHE_VERSION_RE,
`context-mode/context-mode/${currentVersion}`,
);
}
return next;
});
if (after.some((v, i) => v !== before[i])) {
srv.args = after;
mutated = true;
@@ -158,7 +240,7 @@ export function normalizeHooksOnStartup({ pluginRoot, nodePath, platform }) {
const hooksPath = resolve(pluginRoot, "hooks", "hooks.json");
if (existsSync(hooksPath)) {
const original = readFileSync(hooksPath, "utf-8");
if (needsHookNormalization(original)) {
if (needsHookNormalization(original, pluginRoot)) {
const next = normalizeHooksJson(original, nodePath, pluginRoot);
if (next !== original) {
writeFileSync(hooksPath, next, "utf-8");
@@ -174,7 +256,7 @@ export function normalizeHooksOnStartup({ pluginRoot, nodePath, platform }) {
const pluginPath = resolve(pluginRoot, ".claude-plugin", "plugin.json");
if (existsSync(pluginPath)) {
const original = readFileSync(pluginPath, "utf-8");
if (needsHookNormalization(original)) {
if (needsHookNormalization(original, pluginRoot)) {
const next = normalizePluginJson(original, nodePath, pluginRoot);
if (next !== original) {
writeFileSync(pluginPath, next, "utf-8");
@@ -402,3 +402,109 @@ describe("normalizeHooksOnStartup", () => {
).not.toThrow();
});
});
// ─────────────────────────────────────────────────────────
// Slice 5: version-bump regression (#604)
//
// Claude Code's native plugin manager auto-update carries the previous
// version's *already-normalized* hooks.json forward into the new version
// directory. The placeholder is gone, so normalize-hooks short-circuits
// and the stale `…/<old-version>/hooks/<file>.mjs` command paths persist.
// The old version dir has been cleaned up → every hook fires MODULE_NOT_FOUND.
// `ctx-doctor` stays green because it only checks the current dir exists
// and that hooks are *configured*, not that command paths point at it.
//
// Fix: detection + rewrite must also handle stale absolute paths whose
// `context-mode/context-mode/<version>` segment differs from the current
// pluginRoot. See `hooks/cache-heal-utils.mjs` `isStaleNodePath` for the
// precedent on stale-absolute-path repair.
// ─────────────────────────────────────────────────────────
describe("normalize-hooks survives a version bump (#604)", () => {
const NODE = "/usr/bin/node";
const ROOT_V135 = "/cache/context-mode/context-mode/1.0.135";
const ROOT_V136 = "/cache/context-mode/context-mode/1.0.136";
const PLACEHOLDER_SOURCE = JSON.stringify({
hooks: {
SessionStart: [
{
matcher: "",
hooks: [
{
type: "command",
command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs"',
},
],
},
],
},
});
test("re-points an already-normalized hooks.json to the new version (#604)", () => {
// v135 boot: placeholder → absolute 1.0.135 path
const v135 = normalizeHooksJson(PLACEHOLDER_SOURCE, NODE, ROOT_V135);
expect(v135).toContain("/1.0.135/");
// Auto-update carries v135's normalized hooks.json into the 1.0.136 dir;
// the 1.0.136 MCP server boots and normalize runs again with the new root.
const v136 = normalizeHooksJson(v135, NODE, ROOT_V136);
// After the fix: stale `/1.0.135/` segment must be re-pointed to `/1.0.136/`.
// Pre-fix this fails because needsHookNormalization(v135) === false
// (placeholder gone) → normalizeHooksJson short-circuits → v136 === v135.
expect(v136).toContain("/1.0.136/");
expect(v136).not.toContain("/1.0.135/");
});
test("needsHookNormalization detects stale cache-root version segment", () => {
// Already-normalized content with the OLD version segment must still be
// flagged for normalization when the current pluginRoot has a NEW segment.
const v135 = normalizeHooksJson(PLACEHOLDER_SOURCE, NODE, ROOT_V135);
expect(needsHookNormalization(v135, ROOT_V136)).toBe(true);
// Same content + same pluginRoot → no work needed.
expect(needsHookNormalization(v135, ROOT_V135)).toBe(false);
// Placeholder always wins.
expect(needsHookNormalization(PLACEHOLDER_SOURCE, ROOT_V136)).toBe(true);
});
test("normalizeHooksOnStartup self-heals stale hooks.json on next boot (end-to-end)", () => {
const cacheBase = makeTmp();
const v135Dir = join(cacheBase, "context-mode", "context-mode", "1.0.135");
const v136Dir = join(cacheBase, "context-mode", "context-mode", "1.0.136");
mkdirSync(join(v135Dir, "hooks"), { recursive: true });
mkdirSync(join(v136Dir, "hooks"), { recursive: true });
// v135 boot: write fresh placeholder hooks.json + normalize.
writeFileSync(join(v135Dir, "hooks", "hooks.json"), PLACEHOLDER_SOURCE);
normalizeHooksOnStartup({
pluginRoot: v135Dir,
nodePath: NODE,
platform: "linux",
});
const normalizedV135 = readFileSync(
join(v135Dir, "hooks", "hooks.json"),
"utf-8",
);
expect(normalizedV135).toContain("/1.0.135/");
// Claude Code's native auto-update: copy v135's normalized hooks.json
// forward into v136 dir, then clean up v135.
writeFileSync(join(v136Dir, "hooks", "hooks.json"), normalizedV135);
rmSync(v135Dir, { recursive: true, force: true });
// v136 boot: normalize must re-point the stale absolute paths.
normalizeHooksOnStartup({
pluginRoot: v136Dir,
nodePath: NODE,
platform: "linux",
});
const healedV136 = readFileSync(
join(v136Dir, "hooks", "hooks.json"),
"utf-8",
);
expect(healedV136).toContain("/1.0.136/");
expect(healedV136).not.toContain("/1.0.135/");
});
});