From dc9bb9f2d9d28f4dd972f2e5d12224ebcd59ff3b Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Mon, 26 Oct 2026 18:39:04 +0000 Subject: [PATCH] fix(hooks): keep inline resolver backslash-free so hooks parse on Windows On native Windows, Claude Code runs each hook as `bash.exe -c ""` through the Windows command line. That extra unescape layer collapses the backslashes in the WSL path conversion of the inline plugin-root resolver, so `.replace(/\\/g,'/')` reaches node as `.replace(/\/g,'/')` and every hook fails with `SyntaxError: missing ) after argument list`. Build the backslash separator with String.fromCharCode(92) and split/join instead of regex escapes, so the resolver contains no backslash. Conversion behaviour is unchanged. Regenerate every embedded copy (hooks.json, codex-hooks.json, commands/*.md) and refresh hooks.metadata.json fingerprints. Add a test that the resolver and each hook `node -e` program are backslash-free, and run the shipped SessionStart command through bash with backslashes collapsed the way Windows does. Fixes #3746 --- CHANGELOG.md | 1 + commands/auto-update.md | 2 +- commands/instinct-status.md | 2 +- commands/sessions.md | 12 +++--- commands/skill-health.md | 6 +-- commands/skill-host-compat.md | 4 +- hooks/codex-hooks.json | 2 +- hooks/hooks.json | 54 ++++++++++++------------ hooks/hooks.metadata.json | 54 ++++++++++++------------ scripts/lib/install-targets/helpers.js | 6 ++- scripts/lib/resolve-ecc-root.js | 2 +- tests/lib/command-plugin-root.test.js | 30 +++++++++++++ tests/lib/extracted-resolver-wsl.test.js | 7 ++- tests/lib/serialized-hook-wsl.test.js | 10 ++++- 14 files changed, 115 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 764a58e63..dc1237b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - GateGuard prior-search credit no longer counts a search that never saw the target. File-type filters (`rg -t`/`-T`/`--type`/`--type-not`, `fd -e`/`--extension`, the Grep tool's `type`), the Glob tool's pattern, shell glob operands, and git pathspecs now filter like include globs, so `rg -t py tokenizer src` or `Glob **/tokenizer.py` no longer credits `tokenizer.js`. A search confined to the target's own file name (`Glob **/tokenizer.js`, `find -name tokenizer.js`, Grep `glob: 'tokenizer.js'`) is a lookup and never credits it. PowerShell searches keep backslashes as path separators. - GateGuard no longer passes an edit as comment-only when its text contains U+2028, U+2029, or U+0085, which end a JavaScript or C# line comment. - Claude Code home installs (`ecc install --target claude` and `install.sh`) now install under `CLAUDE_CONFIG_DIR` when it is set, matching the directory Claude Code reads its user-level config from. Previously ECC wrote to `~/.claude` regardless, so when `CLAUDE_CONFIG_DIR` redirected the config root the install never loaded ("installs, but nothing loads"); the settings.json, plugin.json, hook commands, bundle and install state were all stranded in `~/.claude` while Claude Code read an empty `CLAUDE_CONFIG_DIR`. The claude-home adapter now resolves its root the same way the runtime does (`CLAUDE_CONFIG_DIR || ~/.claude`), consistent with the opencode adapter and `resolveClaudePaths`. +- Hooks run again on native Windows. Claude Code runs each hook as `bash.exe -c ""` through the Windows command line, and that extra unescape layer collapsed the backslashes in the inline plugin-root resolver's WSL path conversion, so every hook failed with `SyntaxError: missing ) after argument list` before its script started. The resolver now contains no backslash (it builds the separator with `String.fromCharCode(92)`), and a test runs the shipped SessionStart command with backslashes collapsed the same way (#3746). ## 2.2.3 - 2026-10-01 diff --git a/commands/auto-update.md b/commands/auto-update.md index 41e0d43e1..83b0da985 100644 --- a/commands/auto-update.md +++ b/commands/auto-update.md @@ -11,7 +11,7 @@ Update ECC from its upstream repo and regenerate the current context's managed i ```bash # Preview the update without mutating anything -ECC_ROOT="$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));try{if(o.platform()==='linux'){var v=f.readFileSync('/proc/version','utf8').toLowerCase();if(v.includes('microsoft')){var m=d.match(/^([A-Za-z]):[\\\\/]/);if(m){var dl=m[1].toLowerCase();d='/mnt/'+dl+d.slice(2).replace(/\\\\/g,'/')}}}}catch(_){}d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot({probe:p.join('scripts','auto-update.js')})}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i"` +// and the extra argv/MSYS unescape layer collapses backslashes, which broke regex literals (#3746). +// The backslash separator is built with String.fromCharCode(92) instead. +const INLINE_WSL_CONVERSION = `try{if(o.platform()==='linux'){var v=f.readFileSync('/proc/version','utf8').toLowerCase();if(v.includes('microsoft')){var b=String.fromCharCode(92),m=d.match(/^([A-Za-z]):/);if(m&&(d.charAt(2)==='/'||d.charAt(2)===b)){var dl=m[1].toLowerCase();d='/mnt/'+dl+d.slice(2).split(b).join('/')}}}}catch(_){}`; /** * Convert a Windows path to a WSL path. diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index 157744f3a..d34a42c62 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -183,7 +183,7 @@ function getInlineWslConversion() { try { _inlineWslConversion = require('./install-targets/helpers').INLINE_WSL_CONVERSION; } catch (_) { - _inlineWslConversion = `try{if(o.platform()==='linux'){var v=f.readFileSync('/proc/version','utf8').toLowerCase();if(v.includes('microsoft')){var m=d.match(/^([A-Za-z]):[\\\\\\\\/]/);if(m){var dl=m[1].toLowerCase();d='/mnt/'+dl+d.slice(2).replace(/\\\\\\\\/g,'/')}}}}catch(_){}`; + _inlineWslConversion = `try{if(o.platform()==='linux'){var v=f.readFileSync('/proc/version','utf8').toLowerCase();if(v.includes('microsoft')){var b=String.fromCharCode(92),m=d.match(/^([A-Za-z]):/);if(m&&(d.charAt(2)==='/'||d.charAt(2)===b)){var dl=m[1].toLowerCase();d='/mnt/'+dl+d.slice(2).split(b).join('/')}}}}catch(_){}`; } } return _inlineWslConversion; diff --git a/tests/lib/command-plugin-root.test.js b/tests/lib/command-plugin-root.test.js index e9b8f93e3..135f2c00b 100644 --- a/tests/lib/command-plugin-root.test.js +++ b/tests/lib/command-plugin-root.test.js @@ -280,6 +280,36 @@ test('inline resolver copies in commands and hooks match canonical buildInlineRe } }); +// On native Windows, Claude Code runs each hook as `bash.exe -c ""` through the +// Windows command line, and that extra unescape layer collapses backslashes. A regex such as +// `/\\/g` then becomes `/\/g` and every hook fails with a SyntaxError (#3746). The inline +// resolver must therefore contain no backslash at all. +test('inline resolver and hook node -e programs contain no backslashes', () => { + const { buildInlineResolve } = require('../../scripts/lib/resolve-ecc-root'); + assert.ok(!INLINE_RESOLVE.includes('\\'), 'INLINE_RESOLVE must not contain a backslash'); + assert.ok( + !buildInlineResolve({ probe: 'scripts/auto-update.js' }).includes('\\'), + 'buildInlineResolve({ probe }) must not contain a backslash' + ); + + for (const hookFile of ['hooks.json', 'codex-hooks.json']) { + const config = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'hooks', hookFile), 'utf8')); + let checked = 0; + for (const entries of Object.values(config.hooks || {})) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + for (const hook of entry.hooks || []) { + if (typeof hook.command !== 'string' || !hook.command.startsWith('node -e "')) continue; + const program = hook.command.slice('node -e "'.length, hook.command.indexOf('"', 'node -e "'.length)); + assert.ok(!program.includes('\\'), `${hookFile} node -e program must not contain a backslash: ${program.slice(0, 80)}`); + checked++; + } + } + } + assert.ok(checked > 0, `Expected at least one node -e hook command in ${hookFile}`); + } +}); + console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); diff --git a/tests/lib/extracted-resolver-wsl.test.js b/tests/lib/extracted-resolver-wsl.test.js index 2b25ce437..14d462c89 100644 --- a/tests/lib/extracted-resolver-wsl.test.js +++ b/tests/lib/extracted-resolver-wsl.test.js @@ -13,9 +13,8 @@ function test(name, run) { // Serialized Bash quoting is covered separately by serialized-hook-wsl.test.js. test('INLINE_RESOLVE extracted expression converts Windows CLAUDE_CONFIG_DIR on simulated WSL', () => { const { INLINE_RESOLVE } = require('../../scripts/lib/resolve-ecc-root'); - // INLINE_RESOLVE is formatted for embedding inside bash double-quoted `node -e "..."` strings, - // where `\\\\` is unescaped by the shell to `\\` before Node parses the JS source. - const jsResolveExpr = INLINE_RESOLVE.replace(/\\\\\\\\/g, '\\\\'); + // INLINE_RESOLVE is backslash-free, so it embeds verbatim in bash double-quoted `node -e "..."` strings. + const jsResolveExpr = INLINE_RESOLVE; const scriptContent = ` 'use strict'; const Module = require('module'); @@ -83,7 +82,7 @@ test('codex-hooks.json extracted resolver converts Windows CLAUDE_CONFIG_DIR to } } assert.ok(hookResolverJS, 'Could not extract inline resolver IIFE from codex-hooks.json'); - const jsResolveExpr = hookResolverJS.replace(/\\\\\\\\/g, '\\\\'); + const jsResolveExpr = hookResolverJS; const scriptContent = ` 'use strict'; diff --git a/tests/lib/serialized-hook-wsl.test.js b/tests/lib/serialized-hook-wsl.test.js index 55854e476..fa9a2f678 100644 --- a/tests/lib/serialized-hook-wsl.test.js +++ b/tests/lib/serialized-hook-wsl.test.js @@ -67,14 +67,20 @@ try { assert.strictEqual(actual.bootstrap, expected + '/scripts/hooks/plugin-hook-bootstrap.js'); assert.deepStrictEqual(actual.args, ['node', 'scripts/hooks/session-start-bootstrap.js']); console.log('PASS full JSON-decoded SessionStart hook resolves exact WSL path through Bash and forwards continuation'); - const mutation = command.replace(",'/'", ",'_'"); + // Native Windows runs hooks as `bash.exe -c ""` through the Windows command line; + // that extra argv/MSYS layer collapses each `\\` pair to `\` before Bash sees it (#3746). + const windowsCollapsed = command.replace(/\\\\/g, '\\'); + const collapsed = execute(windowsCollapsed); + assert.strictEqual(collapsed.root, expected); + console.log('PASS SessionStart hook still parses and resolves after Windows backslash collapsing'); + const mutation = command.replace(".join('/')", ".join('_')"); assert.notStrictEqual(mutation, command, 'Mutation must modify the shipped replacement'); const broken = execute(mutation); assert.notStrictEqual(broken.root, expected, 'Mutation must be detected by exact expected-path assertion'); console.log('PASS exact path assertion detects a mutated slash replacement'); } catch (error) { failed++; console.error(error.stack); } finally { fs.rmSync(folder, { recursive: true, force: true }); } -console.log('Passed: ' + (failed ? 0 : 2)); +console.log('Passed: ' + (failed ? 0 : 3)); console.log('Failed: ' + failed); process.exitCode = failed ? 1 : 0;