* fix(#1693): don't double-quote $CLAUDE_PROJECT_DIR-anchored hook paths on Windows The installer's #2979 legacy-node rewrite ran every managed node hook path through JSON.stringify on Windows. For local installs the path already carries a "$CLAUDE_PROJECT_DIR"-anchored quoted prefix, so stringifying produced "\"$CLAUDE_PROJECT_DIR\"/...". Node then received an argument starting with a literal " , treated it as relative, and failed MODULE_NOT_FOUND — breaking every node managed hook at once (a self-locking PreToolUse-guard deadlock). projectLegacySettingsHookCommand now emits an already-anchored token verbatim and only JSON.stringify-quotes bare absolute paths (which may contain spaces). Scoped to win32 so non-Windows token-shape behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#1693): add changeset Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
901dabe6f1
commit
dcd1d7f973
5
.changeset/lucky-quails-greet.md
Normal file
5
.changeset/lucky-quails-greet.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 1746
|
||||||
|
---
|
||||||
|
Windows: stop double-quoting $CLAUDE_PROJECT_DIR-anchored managed node hook paths during the #2979 legacy rewrite, which produced "\"$CLAUDE_PROJECT_DIR\"/..." and broke every node managed hook with MODULE_NOT_FOUND (PreToolUse-guard deadlock).
|
||||||
@@ -253,6 +253,15 @@ export function isManagedHookCommand(commandText: unknown, opts: { surface?: str
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a `"$VAR"/rest` anchored hook-script token — a path whose leading
|
||||||
|
* shell variable is already double-quoted with the remainder left bare (the
|
||||||
|
* shape `projectLocalHookPrefix` emits for local installs, e.g.
|
||||||
|
* `"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-x.js`). Such a token is ALREADY a
|
||||||
|
* valid, correctly-quoted shell argument and must never be re-quoted.
|
||||||
|
*/
|
||||||
|
const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Projection helper for legacy settings.json hook rewrites.
|
* Projection helper for legacy settings.json hook rewrites.
|
||||||
*
|
*
|
||||||
@@ -275,8 +284,20 @@ export function projectLegacySettingsHookCommand({
|
|||||||
}): string | null {
|
}): string | null {
|
||||||
if (!absoluteRunner || !scriptPath) return null;
|
if (!absoluteRunner || !scriptPath) return null;
|
||||||
const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
|
const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
|
||||||
|
// #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored
|
||||||
|
// quoted prefix (local installs) is already a valid shell token — only the
|
||||||
|
// variable is quoted, the rest is bare. JSON.stringify-ing it on Windows
|
||||||
|
// yields `"\"$CLAUDE_PROJECT_DIR\"/..."` (escaped quotes inside an outer
|
||||||
|
// quote); node then receives an argument that *starts* with a `"`, treats it
|
||||||
|
// as relative, and dies with MODULE_NOT_FOUND. Emit anchored tokens verbatim;
|
||||||
|
// only bare absolute paths (which may contain spaces, e.g. "Program Files")
|
||||||
|
// need the JSON.stringify quoting. Scoped to win32: the non-Windows branch
|
||||||
|
// already preserves the caller's `scriptToken` (which is the bare anchored
|
||||||
|
// token for these inputs), so it never had the double-quote bug.
|
||||||
const commandScriptToken = platform === 'win32'
|
const commandScriptToken = platform === 'win32'
|
||||||
? JSON.stringify(normalizedScriptPath)
|
? (ANCHORED_HOOK_SCRIPT_TOKEN.test(normalizedScriptPath)
|
||||||
|
? normalizedScriptPath
|
||||||
|
: JSON.stringify(normalizedScriptPath))
|
||||||
: (scriptToken || JSON.stringify(normalizedScriptPath));
|
: (scriptToken || JSON.stringify(normalizedScriptPath));
|
||||||
return projectShellCommandText({
|
return projectShellCommandText({
|
||||||
runnerToken: absoluteRunner,
|
runnerToken: absoluteRunner,
|
||||||
|
|||||||
@@ -187,3 +187,92 @@ describe('bug #3439: shell projection module owns managed-hook policy and legacy
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#1693 regression: Windows legacy-node rewrite must not double-quote a "$CLAUDE_PROJECT_DIR"-anchored local hook path', () => {
|
||||||
|
const winRunner = '"C:/Program Files/nodejs/node.exe"';
|
||||||
|
|
||||||
|
// WHY: a local-install hook path already carries a `"$CLAUDE_PROJECT_DIR"`
|
||||||
|
// anchored prefix (only the variable quoted, rest bare). On Windows the legacy
|
||||||
|
// rewrite previously JSON.stringify'd the whole token, yielding
|
||||||
|
// `"\"$CLAUDE_PROJECT_DIR\"/..."`. node then received an argument starting with
|
||||||
|
// a literal `"`, treated it as relative, and died with MODULE_NOT_FOUND —
|
||||||
|
// breaking every node managed hook at once (self-locking deadlock).
|
||||||
|
test('projectLegacySettingsHookCommand emits the anchored path verbatim, not re-quoted', () => {
|
||||||
|
const anchored = '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js';
|
||||||
|
const command = projectLegacySettingsHookCommand({
|
||||||
|
absoluteRunner: winRunner,
|
||||||
|
scriptPath: anchored,
|
||||||
|
scriptToken: anchored,
|
||||||
|
platform: 'win32',
|
||||||
|
runtime: 'claude',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
command,
|
||||||
|
'"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js',
|
||||||
|
);
|
||||||
|
assert.ok(!command.includes('\\"'), 'must not contain escaped double-quotes');
|
||||||
|
});
|
||||||
|
|
||||||
|
// WHY: the fix must be surgical — a BARE absolute Windows path (no anchored
|
||||||
|
// prefix) can contain spaces ("Program Files") and still REQUIRES quoting.
|
||||||
|
test('projectLegacySettingsHookCommand still quotes a bare absolute Windows path', () => {
|
||||||
|
const abs = 'C:/Program Files App/.claude/hooks/gsd-context-monitor.js';
|
||||||
|
const command = projectLegacySettingsHookCommand({
|
||||||
|
absoluteRunner: winRunner,
|
||||||
|
scriptPath: abs,
|
||||||
|
scriptToken: JSON.stringify(abs),
|
||||||
|
platform: 'win32',
|
||||||
|
runtime: 'claude',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
command,
|
||||||
|
'"C:/Program Files/nodejs/node.exe" "C:/Program Files App/.claude/hooks/gsd-context-monitor.js"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WHY: the anchored short-circuit is scoped to win32. On POSIX the rewrite
|
||||||
|
// already preserved the caller's original `scriptToken` and never had the
|
||||||
|
// double-quote bug, so that behavior must be left byte-identical. scriptPath
|
||||||
|
// is anchored but scriptToken is a DISTINCT single-quoted value: if the
|
||||||
|
// win32 gate were removed, the anchored short-circuit would emit scriptPath
|
||||||
|
// and this assertion would fail — that is what pins the gate.
|
||||||
|
test('projectLegacySettingsHookCommand preserves the original scriptToken for anchored paths on POSIX', () => {
|
||||||
|
const command = projectLegacySettingsHookCommand({
|
||||||
|
absoluteRunner: '"/usr/local/bin/node"',
|
||||||
|
scriptPath: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-statusline.js',
|
||||||
|
scriptToken: "'/x/hooks/gsd-statusline.js'",
|
||||||
|
platform: 'linux',
|
||||||
|
runtime: 'claude',
|
||||||
|
});
|
||||||
|
assert.equal(command, `"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WHY: end-to-end through the installer rewrite — the actual #2979 path that
|
||||||
|
// ran during the user's 1.5.0 -> 1.6.0 local update. Managed node hooks get
|
||||||
|
// the absolute runner + clean anchored path; a non-node-prefixed managed .sh
|
||||||
|
// hook (already correct) is left untouched.
|
||||||
|
test('rewriteLegacyManagedNodeHookCommands produces clean anchored node commands on Windows', () => {
|
||||||
|
const settings = {
|
||||||
|
hooks: {
|
||||||
|
PostToolUse: [
|
||||||
|
{
|
||||||
|
hooks: [
|
||||||
|
{ command: 'node "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const changed = rewriteLegacyManagedNodeHookCommands(settings, winRunner, {
|
||||||
|
platform: 'win32',
|
||||||
|
runtime: 'claude',
|
||||||
|
});
|
||||||
|
assert.equal(changed, true);
|
||||||
|
const rewritten = settings.hooks.PostToolUse[0].hooks[0].command;
|
||||||
|
assert.equal(
|
||||||
|
rewritten,
|
||||||
|
'"C:/Program Files/nodejs/node.exe" "$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-context-monitor.js',
|
||||||
|
);
|
||||||
|
assert.ok(!rewritten.includes('\\"'), 'rewritten command must not contain escaped double-quotes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user