diff --git a/setup b/setup index 2e48fdaf3..22733893f 100755 --- a/setup +++ b/setup @@ -3710,6 +3710,33 @@ _hook_command_path() { return 1 } +# Hook syntax gate (#3032): Claude Code runs these shims through /bin/sh, and a +# shell file that does not parse exits 2, which for a PreToolUse hook blocks the +# tool call in every session on the machine. A half-edited or half-merged hook +# must stop setup with its file and line before any registration is written or +# healed, not install silently. +_HOOK_SYNTAX_PROBLEMS="" +for _hook in bin/gstack-session-update hosts/claude/hooks/question-log-hook \ + hosts/claude/hooks/question-preference-hook hosts/claude/hooks/auq-error-fallback-hook \ + hosts/claude/hooks/timeline-stop-hook autoplan/bin/phase-publication-hook; do + _hook_file="$CANONICAL_GSTACK_ROOT/$_hook" + [ -f "$_hook_file" ] || continue + if ! _hook_err=$(bash -n "$_hook_file" 2>&1); then + _HOOK_SYNTAX_PROBLEMS="${_HOOK_SYNTAX_PROBLEMS} + $_hook_err" + fi + if [ -f "$_hook_file.ts" ] && ! _hook_err=$(bun_cmd build --no-bundle --target=bun "$_hook_file.ts" 2>&1 >/dev/null); then + _HOOK_SYNTAX_PROBLEMS="${_HOOK_SYNTAX_PROBLEMS} + $_hook_file.ts: +$(printf '%s\n' "$_hook_err" | sed 's/^/ /')" + fi +done +if [ -n "$_HOOK_SYNTAX_PROBLEMS" ]; then + echo "gstack setup: refusing to register hooks: a hook file does not parse, and Claude Code would block tool calls with it:$_HOOK_SYNTAX_PROBLEMS" >&2 + echo "Fix: repair the file above (git -C \"$SOURCE_GSTACK_DIR\" status shows half-applied edits or merge conflicts), then re-run ./setup." >&2 + exit 1 +fi + # Heal-first: prune dead gstack hook entries and re-point survivors at the # stable install BEFORE any tag-presence guard below (a dead entry carrying the # tag otherwise blocks re-registration forever — the missing-Stop-hook failure diff --git a/test/setup-hook-syntax-gate.test.ts b/test/setup-hook-syntax-gate.test.ts new file mode 100644 index 000000000..fb47aa1ab --- /dev/null +++ b/test/setup-hook-syntax-gate.test.ts @@ -0,0 +1,85 @@ +/** + * #3032: Claude Code runs gstack's hook shims through /bin/sh. A shim that + * does not parse exits 2, which for a PreToolUse hook blocks the tool call in + * every session. setup's gate parses every registered hook (bash -n on the + * shim, a Bun parse of its TypeScript entry) and stops with the file and line + * before any registration is written or healed. + * + * Runs the real gate block extracted from setup against a fixture install; + * driving all of ./setup here is disproportionate. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8'); +const START = SETUP.indexOf('# Hook syntax gate (#3032)'); +const END = SETUP.indexOf('# Heal-first: prune dead gstack hook entries'); +const GATE = SETUP.slice(START, END); +const HOOKS = ['bin/gstack-session-update', 'hosts/claude/hooks/question-log-hook', 'hosts/claude/hooks/question-preference-hook', + 'hosts/claude/hooks/auq-error-fallback-hook', 'hosts/claude/hooks/timeline-stop-hook', 'autoplan/bin/phase-publication-hook']; + +function fixture(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-syntax-gate-')); + for (const hook of HOOKS) { + fs.mkdirSync(path.dirname(path.join(root, hook)), { recursive: true }); + fs.copyFileSync(path.join(ROOT, hook), path.join(root, hook)); + if (fs.existsSync(path.join(ROOT, `${hook}.ts`))) fs.writeFileSync(path.join(root, `${hook}.ts`), 'export const ok = 1;\n'); + } + return root; +} + +function runGate(root: string) { + const script = `bun_cmd() { bun "$@"; }\nCANONICAL_GSTACK_ROOT='${root}'\nSOURCE_GSTACK_DIR='${root}'\n${GATE}\necho GATE_PASSED`; + const r = spawnSync('bash', ['-c', script], { encoding: 'utf8', timeout: 30_000 }); + return { code: r.status, out: `${r.stdout}${r.stderr}` }; +} + +describe('#3032: setup refuses to register a hook that does not parse', () => { + test('the gate runs before any hook registration is healed or written', () => { + expect(START).toBeGreaterThan(-1); + expect(END).toBeGreaterThan(START); + const writes = [...SETUP.matchAll(/"\$SETTINGS_HOOK" (?:ensure-event|add-event|prune-stale|remove-source)/g)].map(m => m.index!); + expect(writes.length).toBeGreaterThan(0); + expect(Math.min(...writes)).toBeGreaterThan(END); + }); + + test('healthy hooks pass', () => { + const root = fixture(); + try { expect(runGate(root)).toEqual({ code: 0, out: 'GATE_PASSED\n' }); } + finally { fs.rmSync(root, { recursive: true, force: true }); } + }); + + test('a half-merged shell shim stops setup with its file and line', () => { + const root = fixture(); + try { + const shim = path.join(root, 'hosts/claude/hooks/question-preference-hook'); + fs.appendFileSync(shim, '<<<<<<< HEAD\nif [ -n "$x" ]; then\n'); + const r = runGate(root); + expect(r.code).toBe(1); + expect(r.out).toContain('refusing to register hooks'); + expect(r.out).toContain(shim); + expect(r.out).not.toContain('GATE_PASSED'); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }); + + test('a TypeScript entry with a syntax error stops setup with its file', () => { + const root = fixture(); + try { + const ts = path.join(root, 'hosts/claude/hooks/timeline-stop-hook.ts'); + fs.writeFileSync(ts, 'export const x = {;\n'); + const r = runGate(root); + expect(r.code).toBe(1); + expect(r.out).toContain(`${ts}:`); + expect(r.out).toContain('Expected identifier'); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }); + + test("every shipped hook in this checkout parses", () => { + const r = runGate(ROOT); + expect(r.out).toContain('GATE_PASSED'); + }); +});