From 7d1cc4bd168ee588b945c47f0f8b444786fafe7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=BF=97=E8=BE=BE?= Date: Fri, 18 Sep 2026 14:39:44 +0800 Subject: [PATCH 1/3] fix(hooks): run CodeBuddy's Windows hooks through cmd.exe bundled-runtime: CodeBuddy provides cmd.exe on Windows, so the /bin/sh shell gate is a false negative there. POSIX keeps the old check. builtin-hooks: render cmd-syntax commands for codebuddy on win32 and write a teamai.cmd shim, so the PATHEXT lookup can resolve the CLI. hooks: render the project gate in the host shell's syntax and recognise both renderings, so a platform switch cannot leave dead duplicates. tests: win32 cases with a spied platform, so ubuntu CI covers them. --- src/__tests__/hooks-golden.test.ts | 13 ++- src/__tests__/hooks-reconcile-scope.test.ts | 59 +++++++++++++ src/__tests__/hooks-shell-check.test.ts | 52 +++++++++++- src/__tests__/hooks-wrapper.test.ts | 54 ++++++++++++ src/builtin-hooks.ts | 93 +++++++++++++++++---- src/bundled-runtime.ts | 37 ++++++-- src/hooks.ts | 71 +++++++++++++--- 7 files changed, 343 insertions(+), 36 deletions(-) diff --git a/src/__tests__/hooks-golden.test.ts b/src/__tests__/hooks-golden.test.ts index 9221516b..7be72b71 100644 --- a/src/__tests__/hooks-golden.test.ts +++ b/src/__tests__/hooks-golden.test.ts @@ -23,6 +23,16 @@ const cases: Array<[string, string]> = [ ['workbuddy', 'settings.json'], ]; +/** + * Tools whose rendered command is platform-specific, so they have no + * cross-platform baseline: codebuddy is rendered in cmd.exe syntax on Windows + * (its hook runner there is cmd.exe, not a POSIX shell — see + * bundled-runtime.ts), exactly like ZCode, which is absent from the fixture set + * for the same reason. Their Windows shape is pinned by + * hooks-shell-check.test.ts instead, so the anchor stays platform-independent. + */ +const PLATFORM_SPECIFIC_TOOLS = new Set(['codebuddy']); + describe('hooks golden — built-in output is byte-identical to the captured baseline', () => { let tmp: string; beforeEach(async () => { @@ -33,7 +43,8 @@ describe('hooks golden — built-in output is byte-identical to the captured bas }); for (const [tool, file] of cases) { - it(`${tool} output matches golden fixture`, async () => { + const skip = process.platform === 'win32' && PLATFORM_SPECIFIC_TOOLS.has(tool); + it.skipIf(skip)(`${tool} output matches golden fixture`, async () => { const p = path.join(tmp, tool, file); await injectHooks(p, tool); const got = await fse.readFile(p, 'utf-8'); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index 117d76e5..7bd5e513 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -369,3 +369,62 @@ describe('reconcileTeamHooksForConfig — legacy projectRoot sweep', () => { expect(claude.hooks.SessionStart).toHaveLength(1); }); }); + +// ── Project gate rendering per host shell ──────────────────── +// +// A tool whose Windows hook runner is cmd.exe cannot execute a POSIX +// `if [ "$PWD" ... ]` gate: cmd aborts on that syntax, so the whole team hook — +// gate and payload alike — never runs. Pin the cmd rendering for those tools and +// the POSIX rendering for everything else. +describe('project gate rendering per host shell', () => { + const codebuddyOnly = { + toolPaths: { codebuddy: { settings: '.codebuddy/settings.json' } }, + } as unknown as TeamaiConfig; + + async function teamStopCommands(file: string): Promise { + const settings = await fse.readJson(path.join(home, file)); + return (settings.hooks.Stop ?? []) + .filter((e: { description?: string }) => e.description?.startsWith('[teamai:hook:')) + .map((e: { hooks: Array<{ command: string }> }) => e.hooks[0].command); + } + + const telemetryYaml = (tool: string): string => ` +hooks: + - id: telemetry + description: inject telemetry + event: Stop + matcher: "*" + command: python3 .docs/script/inject-telemetry.py + tools: [${tool}] +`; + + it('renders a cmd.exe gate for a tool whose Windows hook runner is cmd.exe', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + try { + await writeYaml(telemetryYaml('codebuddy')); + await fse.ensureDir(path.join(home, '.codebuddy')); + await reconcileTeamHooksForConfig(codebuddyOnly, localConfig()); + + const [command] = await teamStopCommands('.codebuddy/settings.json'); + expect(command.startsWith('echo %CD%\\| findstr /i /b /l /c:"')).toBe(true); + expect(command.endsWith('\\\\" >nul && (python3 .docs/script/inject-telemetry.py)')).toBe(true); + expect(command).not.toContain('$PWD'); + } finally { + platformSpy.mockRestore(); + } + }); + + it('keeps the POSIX gate for a tool whose runner is not cmd.exe', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + try { + await writeYaml(telemetryYaml('claude')); + await reconcileTeamHooksForConfig(teamConfig, localConfig()); + + const [command] = await teamStopCommands('.claude/settings.json'); + expect(command.startsWith('if [ "$PWD" = ')).toBe(true); + expect(command.endsWith('); fi')).toBe(true); + } finally { + platformSpy.mockRestore(); + } + }); +}); diff --git a/src/__tests__/hooks-shell-check.test.ts b/src/__tests__/hooks-shell-check.test.ts index 34790dfb..d5de4ceb 100644 --- a/src/__tests__/hooks-shell-check.test.ts +++ b/src/__tests__/hooks-shell-check.test.ts @@ -67,10 +67,14 @@ describe('hasShell()', () => { }); }); -describe('injectHooksToAllTools — no-shell skip', () => { +describe('injectHooksToAllTools — no-shell skip (posix)', () => { let tmp: string; + let platformSpy: ReturnType; beforeEach(async () => { + // Pin POSIX: on win32 codebuddy has cmd.exe and is never gated on /bin/sh + // (see the win32 describe below). + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); _resetShellCache(); tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'hooks-shell-')); homeState.home = tmp; @@ -78,6 +82,7 @@ describe('injectHooksToAllTools — no-shell skip', () => { }); afterEach(async () => { + platformSpy.mockRestore(); await fse.remove(tmp); }); @@ -149,3 +154,48 @@ describe('injectHooksToAllTools — workbuddy bundled PortableGit sh (win32)', ( expect(await fse.pathExists(path.join(tmp, '.workbuddy', 'settings.json'))).toBe(false); }); }); + +describe('injectHooksToAllTools — codebuddy runs hooks through cmd.exe (win32)', () => { + let tmp: string; + let platformSpy: ReturnType; + + beforeEach(async () => { + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + _resetShellCache(); + tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'cb-cmd-')); + homeState.home = tmp; + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + platformSpy.mockRestore(); + await fse.remove(tmp); + }); + + it('injects codebuddy hooks without /bin/sh, because its hook runner is %ComSpec%', async () => { + shellExists = false; + await fse.ensureDir(path.join(tmp, '.codebuddy')); + + await injectHooksToAllTools({ codebuddy: { settings: '.codebuddy/settings.json' } }, tmp); + + expect(vi.mocked(log.warn)).not.toHaveBeenCalled(); + const settings = await fse.readJson(path.join(tmp, '.codebuddy', 'settings.json')); + const command: string = settings.hooks.SessionStart[0].hooks[0].command; + expect(command).toContain('set "PATH=%USERPROFILE%\\.teamai\\bin;%PATH%"'); + expect(command).toContain('2>nul || exit /b 0'); + // The POSIX form never runs under cmd.exe (no VAR=value prefix, no /dev/null). + expect(command).not.toContain('/dev/null'); + }); + + it('renders the per-matcher variant in cmd syntax too', async () => { + shellExists = false; + await fse.ensureDir(path.join(tmp, '.codebuddy')); + + await injectHooksToAllTools({ codebuddy: { settings: '.codebuddy/settings.json' } }, tmp); + + const settings = await fse.readJson(path.join(tmp, '.codebuddy', 'settings.json')); + const todoWrite = settings.hooks.PostToolUse.find((g: { matcher: string }) => g.matcher === 'TodoWrite'); + expect(todoWrite.hooks[0].command).toContain('hook-dispatch post-tool-use --tool codebuddy --matcher TodoWrite'); + expect(todoWrite.hooks[0].command).toContain('2>nul || exit /b 0'); + }); +}); diff --git a/src/__tests__/hooks-wrapper.test.ts b/src/__tests__/hooks-wrapper.test.ts index 153a26da..cd31f648 100644 --- a/src/__tests__/hooks-wrapper.test.ts +++ b/src/__tests__/hooks-wrapper.test.ts @@ -9,6 +9,7 @@ vi.mock('../utils/logger.js', () => ({ })); import { reconcileHooksToAllTools } from '../hooks.js'; +import { _resetShellCache } from '../builtin-hooks.js'; // Verify that reconcileHooksToAllTools (the pull/init main path) creates the // teamai wrapper at $HOME/.teamai/bin/teamai when workbuddy or codebuddy is present. @@ -138,3 +139,56 @@ describe('reconcileHooksToAllTools — wrapper creation on main inject path', () ).resolves.not.toThrow(); }); }); + +describe('reconcileHooksToAllTools — teamai.cmd wrapper (win32)', () => { + let tmp: string; + let origHome: string | undefined; + let stubCreated = false; + let platformSpy: ReturnType; + + beforeEach(async () => { + tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'hooks-wrapper-cmd-')); + origHome = process.env.HOME; + process.env.HOME = tmp; + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + _resetShellCache(); + + // Create stub index.js so resolveTeamaiEntryScript() succeeds in test env + if (!await fse.pathExists(stubIndexJs)) { + await fse.writeFile(stubIndexJs, '// test stub\n'); + stubCreated = true; + } + }); + + afterEach(async () => { + platformSpy.mockRestore(); + _resetShellCache(); + if (origHome !== undefined) process.env.HOME = origHome; + else delete process.env.HOME; + await fse.remove(tmp); + if (stubCreated) { + await fse.remove(stubIndexJs); + stubCreated = false; + } + }); + + it('writes teamai.cmd next to the POSIX shim so cmd.exe can resolve it', async () => { + await fse.ensureDir(path.join(tmp, '.codebuddy')); + + await reconcileHooksToAllTools( + { codebuddy: { settings: '.codebuddy/settings.json' } }, + tmp, + [], + path.join(tmp, 'managed-hooks.json'), + {}, + ); + + const cmdWrapper = path.join(tmp, '.teamai', 'bin', 'teamai.cmd'); + expect(await fse.pathExists(cmdWrapper)).toBe(true); + const content = await fse.readFile(cmdWrapper, 'utf-8'); + expect(content).toContain('@echo off'); + expect(content).toContain('index.js'); + // The POSIX shim stays in place for the tools whose runner is a POSIX shell. + expect(await fse.pathExists(path.join(tmp, '.teamai', 'bin', 'teamai'))).toBe(true); + }); +}); diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index 27c21490..64a51279 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -22,27 +22,52 @@ import { bundledShellFor, resetBundledRuntimeCache, resolveCodebuddyNode, resolv // // WorkBuddy and CodeBuddy use bundled Node runtimes and their hook // subprocesses may lack the user's PATH, so `teamai` is not found. -// We write a thin wrapper script at `~/.teamai/bin/teamai` that invokes -// the real entry script with the best available Node, then prepend -// `~/.teamai/bin` to PATH in hook commands for WorkBuddy and CodeBuddy. -// The PATH is expressed as `$HOME/.teamai/bin` (shell literal) so that -// the golden fixture output is stable across machines. +// We write a thin wrapper at `~/.teamai/bin/teamai` — plus a `teamai.cmd` +// next to it on Windows, because cmd.exe cannot execute the extensionless sh +// script — that invokes the real entry script with the best available Node, +// then prepend `~/.teamai/bin` to PATH in hook commands for WorkBuddy and +// CodeBuddy. The PATH is expressed as `$HOME/.teamai/bin` (shell literal) so +// that the golden fixture output is stable across machines; the cmd.exe +// variant uses `%USERPROFILE%\.teamai\bin`. // Other tools keep the plain `bash -lc "teamai ..."` form. const TEAMAI_BIN_DIR = '.teamai/bin'; +/** Same directory as TEAMAI_BIN_DIR, in cmd.exe path syntax. */ +const TEAMAI_BIN_DIR_WIN = TEAMAI_BIN_DIR.replace(/\//g, '\\'); const WRAPPER_NAME = 'teamai'; /** - * Tools whose hook commands depend on /bin/sh being available. CodeBuddy - * and WorkBuddy execute hooks via `spawn('/bin/sh', ['-c', command])`, so - * hook injection is skipped when /bin/sh is absent. + * Tools whose hook commands need a shell to execute at all: their hook runner + * hands the rendered `command` string to a shell instead of an argv vector. + * Which shell that is depends on the tool (and platform) — see + * bundled-runtime.ts. Injection is skipped for a tool when no shell resolves, + * because its hook commands could never run. */ export const SHELL_DEPENDENT_TOOLS = new Set(['workbuddy', 'codebuddy']); +/** + * Shell-dependent tools whose Windows hook runner is cmd.exe rather than a + * POSIX shell, so their rendered command must be cmd syntax. WorkBuddy is NOT + * here: its Windows hook runner is the MSYS shell from its bundled + * PortableGit, which executes the POSIX wrapper form. + */ +const WINDOWS_CMD_TOOLS = new Set(['codebuddy']); + +/** + * True when the tool's hook runner on this platform is cmd.exe, so every + * command rendered for it — built-in dispatch and team-hook project gate alike + * — must be cmd syntax. + */ +export function toolUsesCmdShell(tool: string): boolean { + return process.platform === 'win32' && WINDOWS_CMD_TOOLS.has(tool); +} + /** * Check whether /bin/sh exists. Remote containers (e.g. CloudStudio AI - * inference nodes) may lack it, causing CodeBuddy's `spawn /bin/sh` to - * fail with ENOENT on every hook invocation. Exported so the injection + * inference nodes) may lack it, causing a POSIX hook runner's + * `spawn('/bin/sh', ['-c', command])` to fail with ENOENT on every hook + * invocation. Tools whose runner is cmd.exe on Windows are exempt — they are + * covered by their bundledShellFor entry instead. Exported so the injection * entry points can skip hook installation and warn the user. */ let _hasShellCache: boolean | undefined; @@ -88,7 +113,9 @@ export function resolveCliEntry(): string | null { } /** - * Write a `teamai` wrapper script to `~/.teamai/bin/teamai` that invokes + * Write the `teamai` wrapper into `~/.teamai/bin`: the POSIX `teamai` sh + * script, plus a `teamai.cmd` on Windows (cmd.exe resolves commands through + * PATHEXT, so it can never execute the extensionless sh script). Both invoke * the real entry script with the best available Node binary. Idempotent — * overwrites on every init/pull so the paths stay current after upgrades. * @@ -112,9 +139,21 @@ export function ensureTeamaiWrapper(): string | null { '', ].join('\n'); + const cmdScript = [ + '@echo off', + 'rem Auto-generated by teamai — do not edit.', + 'rem Wrapper that invokes teamai CLI with a known Node binary so hooks', + 'rem work in environments without PATH (e.g. CodeBuddy IDE hook subprocess).', + `"${nodeBin}" "${entryScript}" %*`, + '', + ].join('\r\n'); + try { fs.mkdirSync(binDir, { recursive: true }); fs.writeFileSync(wrapperPath, script, { mode: 0o755 }); + if (process.platform === 'win32') { + fs.writeFileSync(path.join(binDir, `${WRAPPER_NAME}.cmd`), cmdScript); + } return binDir; } catch { return null; @@ -122,10 +161,9 @@ export function ensureTeamaiWrapper(): string | null { } /** - * Per-tool variant of hasShell(). A tool that bundles its own shell (see - * bundled-runtime.ts) can execute hook commands even where /bin/sh is - * absent; everything else keeps the conservative /bin/sh check. POSIX - * always uses /bin/sh. + * Per-tool variant of hasShell(). A tool that provides a shell for its hook + * commands (see bundled-runtime.ts) can execute them even where /bin/sh is + * absent; everything else keeps the conservative /bin/sh check. */ function hasShellFor(tool: string): boolean { if (bundledShellFor(tool)) return true; @@ -189,6 +227,24 @@ function getWrapperDispatchCommand(event: string, tool: string, matcher?: string return `PATH="$HOME/${TEAMAI_BIN_DIR}:$PATH" teamai hook-dispatch ${event} --tool ${tool}${matcherArg} 2>/dev/null || true`; } +/** + * cmd.exe counterpart of getWrapperDispatchCommand, for tools whose Windows + * hook runner is cmd.exe rather than a POSIX shell. cmd.exe has no + * `VAR=value command` prefix, no /dev/null and no `|| true`, so the POSIX form + * above can never run there — it fails on its first token, whose `PATH=...` + * assignment cmd reads as a command name. Emit the cmd equivalent: prepend the + * wrapper dir to PATH (cmd resolves `teamai` to `teamai.cmd` through PATHEXT, + * falling through to the npm shim further down PATH) and force exit 0 on + * failure, mirroring the POSIX `|| true` — CodeBuddy reads a non-zero hook + * status as `allowed:false`, which would BLOCK a UserPromptSubmit instead of + * failing open. The PATH value uses the `%USERPROFILE%` cmd literal so that + * golden fixture output stays stable across machines. + */ +function getCmdWrapperDispatchCommand(event: string, tool: string, matcher?: string): string { + const matcherArg = matcher && matcher !== '*' ? ` --matcher ${matcher}` : ''; + return `set "PATH=%USERPROFILE%\\${TEAMAI_BIN_DIR_WIN};%PATH%" && teamai hook-dispatch ${event} --tool ${tool}${matcherArg} 2>nul || exit /b 0`; +} + /** Canonical, ordered description of each built-in hook. Order is load-bearing * for byte-compat (it fixes array order within each event). */ interface BuiltinHookSpec { @@ -222,7 +278,8 @@ const BUILTIN_HOOK_SPECS: BuiltinHookSpec[] = [ * same HookDef into each tool's on-disk shape. * * GUI tools (WorkBuddy, CodeBuddy) use the wrapper dispatch command so their - * hook subprocesses can find `teamai` even without the user's full PATH. + * hook subprocesses can find `teamai` even without the user's full PATH. On + * Windows the tools in WINDOWS_CMD_TOOLS get the cmd.exe syntax variant. */ const WRAPPER_TOOLS = SHELL_DEPENDENT_TOOLS; @@ -232,7 +289,9 @@ export function builtinHookDefs(tool: string): HookDef[] { const withTimeout = tool === 'cursor' || tool === 'copilot' || tool === 'workbuddy' || tool === 'codebuddy'; const buildCommand = tool === 'zcode' ? getRawDispatchCommand - : WRAPPER_TOOLS.has(tool) ? getWrapperDispatchCommand : getDispatchCommand; + : WRAPPER_TOOLS.has(tool) + ? (toolUsesCmdShell(tool) ? getCmdWrapperDispatchCommand : getWrapperDispatchCommand) + : getDispatchCommand; return BUILTIN_HOOK_SPECS.map((spec) => ({ source: 'builtin' as const, key: spec.key, diff --git a/src/bundled-runtime.ts b/src/bundled-runtime.ts index 7e25edcf..5899936e 100644 --- a/src/bundled-runtime.ts +++ b/src/bundled-runtime.ts @@ -1,7 +1,9 @@ // Bundled-runtime resolution: where GUI tools (WorkBuddy, CodeBuddy) ship -// their own Node and shell, and which of them bundle a shell their hook -// commands can execute with. All layout knowledge for these runtimes lives -// here so hook injection can stay tool-agnostic. +// their own Node and shell, and which of them provide a shell their hook +// commands can execute with — either a POSIX shell they bundle themselves +// (WorkBuddy's PortableGit) or one the OS guarantees (CodeBuddy's cmd.exe on +// Windows). All layout knowledge for these runtimes lives here so hook +// injection can stay tool-agnostic. import fs from 'node:fs'; import path from 'node:path'; import { getUserHome } from './utils/home.js'; @@ -10,10 +12,12 @@ const WORKBUDDY_BUNDLED_NODE_DIR = '.workbuddy/bundled/node/versions'; const WORKBUDDY_PORTABLE_GIT_DIR = '.workbuddy/binaries/PortableGit/versions'; let _wbShellCache: string | null | undefined; +let _cbShellCache: string | null | undefined; /** Reset cached bundled-runtime lookups. Test-only. */ export function resetBundledRuntimeCache(): void { _wbShellCache = undefined; + _cbShellCache = undefined; } /** @@ -119,16 +123,37 @@ function resolveWorkbuddyShell(): string | null { } /** - * Tools that bundle a shell their hook commands can execute with, per tool id. + * The shell CodeBuddy runs hook commands with on Windows. + * + * CodeBuddy's hook runner executes a hook's `command` string through + * `child_process.spawn(command, [], { shell: true })` (genie's + * HookExecutorImpl), which on Windows goes through %ComSpec% — cmd.exe, a shell + * the OS always provides — and NOT /bin/sh. Windows builds of the CodeBuddy IDE + * ship no POSIX shell at all (no sh.exe/bash.exe anywhere in the install tree), + * so gating the tool on /bin/sh is a false negative there. POSIX builds keep + * the conservative /bin/sh check. Memoized like its WorkBuddy sibling. + */ +function resolveCodebuddyShell(): string | null { + if (_cbShellCache === undefined) { + _cbShellCache = process.platform === 'win32' + ? (process.env.ComSpec?.trim() || 'cmd.exe') + : null; + } + return _cbShellCache; +} + +/** + * Tools that provide a shell their hook commands can execute with, per tool id. * A tool without an entry falls back to the conservative /bin/sh gate. */ const BUNDLED_SHELLS: Record string | null> = { workbuddy: resolveWorkbuddyShell, + codebuddy: resolveCodebuddyShell, }; /** - * Return the bundled shell for a tool, or null when the tool does not bundle - * one (the caller should then fall back to the /bin/sh check). + * Return the shell a tool provides for hook commands, or null when it provides + * none (the caller should then fall back to the /bin/sh check). */ export function bundledShellFor(tool: string): string | null { const resolver = BUNDLED_SHELLS[tool]; diff --git a/src/hooks.ts b/src/hooks.ts index 8e7208a7..f3e47abc 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -17,7 +17,7 @@ import { import type { HookDef, TeamaiConfig, LocalConfig } from './types.js'; import { isSelfMode } from './types.js'; import { activeRoleIds } from './roles.js'; -import { builtinHookDefs, applyBuiltinOverride, skipToolsWithoutShell } from './builtin-hooks.js'; +import { builtinHookDefs, applyBuiltinOverride, skipToolsWithoutShell, toolUsesCmdShell } from './builtin-hooks.js'; import type { BuiltinHookOverride } from './builtin-hooks.js'; import { resolveTeamHooks } from './resources/hooks.js'; import { getUserHome } from './utils/home.js'; @@ -277,29 +277,54 @@ function canonicalProjectRoot(projectRoot: string): string { try { return realpathSync.native(projectRoot); } catch { return path.resolve(projectRoot); } } -/** Keep a project-scope team hook from firing in every project on the machine. */ -function gateTeamHookCommand(command: string, projectRoot?: string): string { +/** + * cmd.exe equivalent of the POSIX project gate, as a prefix that resolves to + * true only inside `root`. Appending the separator makes the gate match the + * root itself and anything under it, while a sibling whose name merely shares + * the prefix (`C:\a\proj` vs `C:\a\proj-2`) does not. The pattern ends in `\\` + * because findstr's CRT argument parser consumes one backslash; `/l` keeps it + * literal, `/b` anchors it at the start of the line, and `/i` matches the + * case-insensitive Windows path. + */ +function cmdProjectGate(root: string): string { + return `echo %CD%\\| findstr /i /b /l /c:"${root}\\\\" >nul`; +} + +/** + * Keep a project-scope team hook from firing in every project on the machine. + * The gate is rendered in the syntax of the shell that will actually run it: + * cmd.exe for tools whose Windows hook runner is cmd.exe — a POSIX + * `if [ "$PWD" ... ]` there is a syntax error that kills the whole command, + * gate and payload alike, before it ever runs — and POSIX sh for every other + * tool. + */ +function gateTeamHookCommand(command: string, projectRoot: string | undefined, tool: string): string { if (!projectRoot) return command; - const root = shellQuote(canonicalProjectRoot(projectRoot)); - return `if [ "$PWD" = ${root} ] || case "$PWD" in ${root}/*) true;; *) false;; esac; then (${command}); fi`; + const root = canonicalProjectRoot(projectRoot); + if (toolUsesCmdShell(tool)) return `${cmdProjectGate(root)} && (${command})`; + const quoted = shellQuote(root); + return `if [ "$PWD" = ${quoted} ] || case "$PWD" in ${quoted}/*) true;; *) false;; esac; then (${command}); fi`; } +/** Recognise a project gate written by either renderer (entries outlive a platform switch). */ function isGatedForProject(command: string, projectRoot: string): boolean { - return command.startsWith(`if [ "$PWD" = ${shellQuote(canonicalProjectRoot(projectRoot))} ]`); + const root = canonicalProjectRoot(projectRoot); + return command.startsWith(`if [ "$PWD" = ${shellQuote(root)} ]`) + || command.startsWith(cmdProjectGate(root)); } function isProjectGatedCommand(command: string): boolean { - return command.startsWith('if [ "$PWD" = '); + return command.startsWith('if [ "$PWD" = ') || command.startsWith('echo %CD%\\| findstr '); } -function scopedTeamDefs(teamDefs: HookDef[], projectRoot?: string): HookDef[] { +function scopedTeamDefs(teamDefs: HookDef[], projectRoot: string | undefined, tool: string): HookDef[] { if (!projectRoot) return teamDefs; - return teamDefs.map((def) => ({ ...def, command: gateTeamHookCommand(def.command, projectRoot) })); + return teamDefs.map((def) => ({ ...def, command: gateTeamHookCommand(def.command, projectRoot, tool) })); } function manifestRecordsForTool(teamDefs: HookDef[], tool: string, removeAll: boolean, projectRoot?: string): ManagedHookRecord[] { if (removeAll) return []; - return teamDefsForTool(scopedTeamDefs(teamDefs, projectRoot), tool).map((d) => ({ + return teamDefsForTool(scopedTeamDefs(teamDefs, projectRoot, tool), tool).map((d) => ({ id: d.key, event: d.event, ...(d.matcher && d.matcher !== '*' ? { matcher: d.matcher } : {}), @@ -445,6 +470,16 @@ function isTeamClaudeEntry(entry: HookMatcher): boolean { return (entry.description ?? '').startsWith(TEAMAI_CUSTOM_HOOK_PREFIX); } +/** The hook id carried by a team entry's marker, `[teamai:hook:] …`. */ +function teamHookIdOf(description: string | undefined): string | null { + const marker = description ?? ''; + if (!marker.startsWith(TEAMAI_CUSTOM_HOOK_PREFIX)) return null; + const end = marker.indexOf(']'); + return end > TEAMAI_CUSTOM_HOOK_PREFIX.length + ? marker.slice(TEAMAI_CUSTOM_HOOK_PREFIX.length, end) + : null; +} + async function reconcileClaudeFormat( settingsPath: string, tool: string, @@ -457,6 +492,11 @@ async function reconcileClaudeFormat( // Built-in management never removes team hooks; team hooks are reconciled only // when a team pass is active (manifest present). This keeps the builtin-only // refresh path (injectHooks / autoMigrate) non-destructive to team hooks (§5). + // Hook ids this reconcile declares for the tool, used to recognise our own + // entries even when an older CLI rendered them differently. + const desiredTeamIds = new Set( + teamDefs.filter((d) => !d.tools || d.tools.includes(tool)).map((d) => d.key), + ); const isManaged = (e: HookMatcher): boolean => { if (isBuiltinClaudeEntry(e) || (!!opts.removeAll && isAgentClaudeEntry(e))) return true; if (!teamActive || !isTeamClaudeEntry(e)) return false; @@ -465,6 +505,15 @@ async function reconcileClaudeFormat( // project B pull must not delete project A's hooks. if (opts.teamHookProjectRoot) { const command = e.hooks?.[0]?.command ?? ''; + // An entry gated for this project belongs to this project even when an + // older CLI rendered the gate in another syntax (or the payload changed): + // replace it instead of leaving a dead duplicate that removal can no + // longer match. + if (isGatedForProject(command, opts.teamHookProjectRoot)) { + if (opts.removeAll) return true; + const id = teamHookIdOf(e.description); + if (id !== null && desiredTeamIds.has(id)) return true; + } return desiredTeamCommands.has(command) || priorTeamCommands.has(command); } return true; @@ -942,7 +991,7 @@ export async function reconcileHooks( ? allPriorRecords.filter((r) => isGatedForProject(r.command, opts.teamHookProjectRoot!)) : allPriorRecords; const priorTeamCommands = new Set(priorRecords.map((r) => r.command)); - const scopedDefs = scopedTeamDefs(teamDefs, opts.teamHookProjectRoot); + const scopedDefs = scopedTeamDefs(teamDefs, opts.teamHookProjectRoot, tool); const desiredTeamCommands = new Set(scopedDefs.filter((d) => !d.tools || d.tools.includes(tool)).map((d) => d.command)); const format = detectFormat(tool); From 89fbd57e44d24065e175f46ff89dbac7987acf85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=BF=97=E8=BE=BE?= Date: Sun, 20 Sep 2026 11:31:03 +0800 Subject: [PATCH 2/3] fix(hooks): exit 0 on a project-gate miss and align the cmd wrapper path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hooks: the cmd gate `${gate} && (payload)` let a gate miss inherit findstr's exit status — non-zero is surfaced as a hook error, and exit 2 blocks UserPromptSubmit. Use `& if not errorlevel 1 (payload) else exit /b 0`: a miss exits 0, a match still passes the payload's own status through. builtin-hooks: the cmd wrapper searched `%USERPROFILE%\.teamai\bin` while the writer uses getUserHome(), so HOME/USERPROFILE could diverge and `teamai` was never found. Embed the resolved bin dir; drop TEAMAI_BIN_DIR_WIN. tests: update the cmd-gate and codebuddy PATH assertions. --- src/__tests__/hooks-reconcile-scope.test.ts | 6 +++++- src/__tests__/hooks-shell-check.test.ts | 6 +++++- src/builtin-hooks.ts | 23 ++++++++++++++------- src/hooks.ts | 13 +++++++++++- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index 7bd5e513..7957068f 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -407,7 +407,11 @@ hooks: const [command] = await teamStopCommands('.codebuddy/settings.json'); expect(command.startsWith('echo %CD%\\| findstr /i /b /l /c:"')).toBe(true); - expect(command.endsWith('\\\\" >nul && (python3 .docs/script/inject-telemetry.py)')).toBe(true); + // Outside the project the gate must exit 0 (a non-zero status would make + // CodeBuddy treat UserPromptSubmit as allowed:false and block the prompt), + // while the payload's own status is passed through inside it. + expect(command.endsWith('\\\\" >nul & if not errorlevel 1 (python3 .docs/script/inject-telemetry.py) else exit /b 0')).toBe(true); + expect(command).not.toContain('&& (python3'); expect(command).not.toContain('$PWD'); } finally { platformSpy.mockRestore(); diff --git a/src/__tests__/hooks-shell-check.test.ts b/src/__tests__/hooks-shell-check.test.ts index d5de4ceb..e1bb1840 100644 --- a/src/__tests__/hooks-shell-check.test.ts +++ b/src/__tests__/hooks-shell-check.test.ts @@ -181,7 +181,11 @@ describe('injectHooksToAllTools — codebuddy runs hooks through cmd.exe (win32) expect(vi.mocked(log.warn)).not.toHaveBeenCalled(); const settings = await fse.readJson(path.join(tmp, '.codebuddy', 'settings.json')); const command: string = settings.hooks.SessionStart[0].hooks[0].command; - expect(command).toContain('set "PATH=%USERPROFILE%\\.teamai\\bin;%PATH%"'); + // Must point at the SAME bin dir the wrapper writer resolved through + // getUserHome() (HOME wins over USERPROFILE) — a %USERPROFILE% literal could + // name a different directory and the hook would silently miss the shim. + expect(command).toContain(`set "PATH=${path.join(tmp, '.teamai', 'bin')};%PATH%"`); + expect(command).not.toContain('%USERPROFILE%'); expect(command).toContain('2>nul || exit /b 0'); // The POSIX form never runs under cmd.exe (no VAR=value prefix, no /dev/null). expect(command).not.toContain('/dev/null'); diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index 64a51279..19f4e12a 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -26,14 +26,13 @@ import { bundledShellFor, resetBundledRuntimeCache, resolveCodebuddyNode, resolv // next to it on Windows, because cmd.exe cannot execute the extensionless sh // script — that invokes the real entry script with the best available Node, // then prepend `~/.teamai/bin` to PATH in hook commands for WorkBuddy and -// CodeBuddy. The PATH is expressed as `$HOME/.teamai/bin` (shell literal) so -// that the golden fixture output is stable across machines; the cmd.exe -// variant uses `%USERPROFILE%\.teamai\bin`. +// CodeBuddy. The POSIX PATH is expressed as `$HOME/.teamai/bin` (shell +// literal) so that the golden fixture output is stable across machines; the +// cmd.exe variant embeds the same bin dir resolved through getUserHome(), the +// resolver the wrapper writer uses, so write and lookup cannot diverge. // Other tools keep the plain `bash -lc "teamai ..."` form. const TEAMAI_BIN_DIR = '.teamai/bin'; -/** Same directory as TEAMAI_BIN_DIR, in cmd.exe path syntax. */ -const TEAMAI_BIN_DIR_WIN = TEAMAI_BIN_DIR.replace(/\//g, '\\'); const WRAPPER_NAME = 'teamai'; /** @@ -237,12 +236,20 @@ function getWrapperDispatchCommand(event: string, tool: string, matcher?: string * falling through to the npm shim further down PATH) and force exit 0 on * failure, mirroring the POSIX `|| true` — CodeBuddy reads a non-zero hook * status as `allowed:false`, which would BLOCK a UserPromptSubmit instead of - * failing open. The PATH value uses the `%USERPROFILE%` cmd literal so that - * golden fixture output stays stable across machines. + * failing open. + * + * The PATH value is the bin dir resolved through getUserHome() — the SAME + * resolver ensureTeamaiWrapper() writes `teamai.cmd` through — embedded as a + * concrete path. A `%USERPROFILE%` literal here would disagree with the writer + * whenever HOME wins (Git Bash, a custom environment) or USERPROFILE is absent: + * the shim would land in one directory while the hook searched another, and the + * hook would silently fail to find the CLI. The POSIX form keeps its `$HOME` + * literal because getUserHome() prefers HOME and the shell expands it. */ function getCmdWrapperDispatchCommand(event: string, tool: string, matcher?: string): string { const matcherArg = matcher && matcher !== '*' ? ` --matcher ${matcher}` : ''; - return `set "PATH=%USERPROFILE%\\${TEAMAI_BIN_DIR_WIN};%PATH%" && teamai hook-dispatch ${event} --tool ${tool}${matcherArg} 2>nul || exit /b 0`; + const binDir = path.join(getUserHome(), TEAMAI_BIN_DIR); + return `set "PATH=${binDir};%PATH%" && teamai hook-dispatch ${event} --tool ${tool}${matcherArg} 2>nul || exit /b 0`; } /** Canonical, ordered description of each built-in hook. Order is load-bearing diff --git a/src/hooks.ts b/src/hooks.ts index b31c1dea..3c8d187d 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -298,11 +298,22 @@ function cmdProjectGate(root: string): string { * `if [ "$PWD" ... ]` there is a syntax error that kills the whole command, * gate and payload alike, before it ever runs — and POSIX sh for every other * tool. + * + * Exit-status contract, identical for both renderings: outside the project the + * gate is a no-op that exits 0, and inside it the command's own status is + * passed through. A gate mismatch that returned non-zero would make CodeBuddy + * read the hook as `allowed:false` and BLOCK every UserPromptSubmit outside the + * project, so the cmd form must not inherit `findstr`'s failure status. That is + * also why the cmd form is not `${gate} || exit /b 0 && (…)`: the `||` would + * swallow a genuine payload failure along with the mismatch, losing the + * pass-through the POSIX `if …; then …; fi` gives for free. */ function gateTeamHookCommand(command: string, projectRoot: string | undefined, tool: string): string { if (!projectRoot) return command; const root = canonicalProjectRoot(projectRoot); - if (toolUsesCmdShell(tool)) return `${cmdProjectGate(root)} && (${command})`; + if (toolUsesCmdShell(tool)) { + return `${cmdProjectGate(root)} & if not errorlevel 1 (${command}) else exit /b 0`; + } const quoted = shellQuote(root); return `if [ "$PWD" = ${quoted} ] || case "$PWD" in ${quoted}/*) true;; *) false;; esac; then (${command}); fi`; } From 6acd02fcd5241583ac4f1cb7f8841c08502210c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=BF=97=E8=BE=BE?= Date: Mon, 21 Sep 2026 17:20:35 +0800 Subject: [PATCH 3/3] fix: escape cmd project gate for CodeBuddy hooks on Windows Cmd shell metacharacters in project roots break the team-hook project gate; escape the root literal and match with findstr. Sync tests and docs. --- docs/windows-hooks.md | 49 +++++++----- docs/windows-hooks.zh-CN.md | 43 ++++++----- src/__tests__/hooks-reconcile-scope.test.ts | 82 ++++++++++++++++++++- src/hooks.ts | 44 +++++++++-- 4 files changed, 172 insertions(+), 46 deletions(-) diff --git a/docs/windows-hooks.md b/docs/windows-hooks.md index 073b9e0b..18834449 100644 --- a/docs/windows-hooks.md +++ b/docs/windows-hooks.md @@ -7,11 +7,17 @@ ## TL;DR -On Windows the hooks TeamAI injects use a bare `bash` launcher that silently -crashes (the WSL `bash` ships Node 18, which can't parse the TeamAI bundle), so -the hooks are effectively dead — `|| true` hides the failure. In addition, -`codebuddy` / `workbuddy` hooks are **never written at all** because TeamAI's -shell detection (`fs.existsSync('/bin/sh')`) is always false on Windows. +On older TeamAI versions the hooks injected on Windows used a bare `bash` +launcher that silently crashes (the WSL `bash` ships Node 18, which can't parse +the TeamAI bundle), so the hooks were effectively dead — `|| true` hid the +failure. The same versions never wrote `codebuddy` / `workbuddy` hooks at all, +because shell detection (`fs.existsSync('/bin/sh')`) is always false on Windows. + +Current `teamai` handles Windows itself, so the user-side workaround below is +only needed on an older version: hook commands launch through an absolute Git +Bash path, and each GUI tool resolves its own hook shell — WorkBuddy through its +bundled PortableGit `sh.exe`, and **CodeBuddy through cmd.exe** (`%ComSpec%`), +which every Windows install provides. Neither tool is skipped. The durable user-side fix combines two mechanisms so hooks fire no matter what `teamai` writes: @@ -51,7 +57,7 @@ bundle needs a newer Node, so every hook invocation crashes silently. Because the command ends in `|| true`, the crash is swallowed and nothing is logged — hooks never fire, yet `teamai doctor` still reports them as "present". -### Failure mode 2 — `hasShell()` skips CodeBuddy / WorkBuddy +### Failure mode 2 — `hasShell()` skipped CodeBuddy / WorkBuddy `src/builtin-hooks.ts` gates shell-dependent tools on `hasShell()`: @@ -69,14 +75,16 @@ export function hasShell(): boolean { ``` `/bin/sh` does not exist on Windows, so `hasShell()` is `false` and -`skipToolsWithoutShell()` adds `codebuddy` / `workbuddy` -(`SHELL_DEPENDENT_TOOLS`) to the skip set. Those two agents get **no hooks at -all** on Windows, even when everything else works. +`skipToolsWithoutShell()` added `codebuddy` / `workbuddy` +(`SHELL_DEPENDENT_TOOLS`) to the skip set — those two agents got **no hooks at +all** on Windows, even when everything else worked. -> Note: `workbuddy` has a partial escape hatch — `hasShellFor()` returns `true` -> if `bundledShellFor(tool)` finds WorkBuddy's bundled PortableGit `sh.exe`. But -> that only helps if that exact binary is present, and `codebuddy` has no -> bundled shell, so it is skipped unconditionally on Windows. +That skip is gone: gating now asks each tool for its own hook shell first +(`hasShellFor()` → `bundledShellFor()`). `workbuddy` resolves through the +PortableGit `sh.exe` it ships; `codebuddy` resolves through cmd.exe, because +CodeBuddy's Windows hook runner is `%ComSpec%` — it executes a hook's `command` +via `child_process.spawn(command, [], { shell: true })` — and every Windows +install provides cmd.exe. Only a tool with no resolvable shell is skipped. --- @@ -85,7 +93,8 @@ all** on Windows, even when everything else works. 1. **Bare `bash` → WSL Node 18.** Windows `PATH` resolves `bash` to the WSL launcher before Git Bash; WSL Node 18 can't parse the TeamAI bundle. 2. **`hasShell()` Windows bug.** `fs.existsSync('/bin/sh')` is never true on - Windows, so hook injection for `codebuddy` / `workbuddy` is skipped. + Windows, which used to skip hook injection for `codebuddy` / `workbuddy`; + the per-tool `bundledShellFor()` resolver now covers them. 3. **WSL path translation.** A WSL-side wrapper that `exec`s the Windows Node with a `/mnt/c/...` path gets mangled into `C:\mnt\c\...`, causing `MODULE_NOT_FOUND`. @@ -183,9 +192,9 @@ dispatch (bare bash/WSL): claude=0 codex=0 zcode=0 codebuddy=0 qoder=0 workbuddy break again. - **Requires WSL for Mechanism B.** On a machine without WSL, only Mechanism A (the Git-Bash absolute path currently in the files) works. -- **Does not patch TeamAI itself.** The upstream `hasShell()` bug and the - bare-`bash` default are not fixed inside `teamai-cli`. After - `npm update teamai-cli` re-apply this fix (or rely on the WSL wrapper). +- **Does not patch older TeamAI versions.** The upstream `hasShell()` skip and + the bare-`bash` default are fixed in current `teamai-cli`; `npm update + teamai-cli` picks the fixes up and the workaround can then be dropped. - **macOS / Linux need no fix.** There, bare `bash` already resolves to the system Node and works natively. - **`teamai doctor` `gh` check can be a false negative.** It may spawn `gh` @@ -198,7 +207,8 @@ dispatch (bare bash/WSL): claude=0 codex=0 zcode=0 codebuddy=0 qoder=0 workbuddy ## Suggested upstream fix (for maintainers) -Two small changes would make Windows work out of the box: +Two small changes would make Windows work out of the box — both have since +shipped in `teamai-cli`, so this section is kept for context: ### 1. Make `hasShell()` Windows-aware @@ -231,7 +241,8 @@ export function hasShell(): boolean { } ``` -This alone would let `codebuddy` / `workbuddy` hooks be injected on Windows. +Current `teamai` achieves this through `hasShellFor()` → `bundledShellFor()`, so +`codebuddy` / `workbuddy` hooks are injected on Windows today. ### 2. Default the dispatch command to an absolute Git Bash path on Windows diff --git a/docs/windows-hooks.zh-CN.md b/docs/windows-hooks.zh-CN.md index b6f181e1..cdadd74f 100644 --- a/docs/windows-hooks.zh-CN.md +++ b/docs/windows-hooks.zh-CN.md @@ -6,11 +6,16 @@ ## 摘要(TL;DR) -在 Windows 上,TeamAI 注入的钩子使用裸 `bash` 启动器,会**静默崩溃**(WSL 的 -`bash` 自带 Node 18,无法解析 TeamAI 打包产物),因此钩子实际上处于失效状态—— -`|| true` 把错误吞掉了。此外,`codebuddy` / `workbuddy` 的钩子**根本不会被写入**, -因为 TeamAI 的 shell 检测(`fs.existsSync('/bin/sh')`)在 Windows 上永远为 -假。 +在较早版本的 TeamAI 上,Windows 注入的钩子使用裸 `bash` 启动器,会**静默崩溃** +(WSL 的 `bash` 自带 Node 18,无法解析 TeamAI 打包产物),因此钩子实际上处于失效 +状态——`|| true` 把错误吞掉了。同样的版本中,`codebuddy` / `workbuddy` 的钩子 +**根本不会被写入**,因为 shell 检测(`fs.existsSync('/bin/sh')`)在 Windows 上 +永远为假。 + +当前版本的 `teamai` 已自行处理 Windows,因此下文的用户侧绕行方案仅在旧版本上需要: +钩子命令通过 Git Bash 的绝对路径启动,且每个 GUI 工具都会解析各自的钩子 shell—— +WorkBuddy 使用其自带的 PortableGit `sh.exe`,**CodeBuddy 使用 cmd.exe** +(`%ComSpec%`),任何 Windows 安装都提供 cmd.exe。两者都不再被跳过。 持久化的用户侧修复结合两种机制,无论 `teamai` 写入什么都能让钩子触发: @@ -47,7 +52,7 @@ 结尾,崩溃被吞掉、不记录日志——钩子永不触发,但 `teamai doctor` 仍报告它们 “存在”。 -### 故障模式 2 — `hasShell()` 跳过 CodeBuddy / WorkBuddy +### 故障模式 2 — `hasShell()` 曾跳过 CodeBuddy / WorkBuddy `src/builtin-hooks.ts` 用 `hasShell()` 来门控依赖 shell 的工具: @@ -65,13 +70,15 @@ export function hasShell(): boolean { ``` `/bin/sh` 在 Windows 上不存在,因此 `hasShell()` 为 `false`,`skipToolsWithoutShell()` -会把 `codebuddy` / `workbuddy`(`SHELL_DEPENDENT_TOOLS`)加入跳过集合。这两个代理 +曾把 `codebuddy` / `workbuddy`(`SHELL_DEPENDENT_TOOLS`)加入跳过集合,这两个代理 在 Windows 上**完全不会获得钩子**,即使其他一切都正常。 -> 说明:`workbuddy` 有一个局部逃生通道——若 `bundledShellFor(tool)` 找到了 -> WorkBuddy 自带的 PortableGit `sh.exe`,`hasShellFor()` 会返回 `true`。但这仅在 -> 该二进制确实存在时才有用,而 `codebuddy` 没有自带 shell,因此在 Windows 上会被 -> 无条件跳过。 +该跳过已不再存在:门控会先向每个工具询问其自身的钩子 shell +(`hasShellFor()` → `bundledShellFor()`)。`workbuddy` 通过其自带的 PortableGit +`sh.exe` 解析;`codebuddy` 通过 cmd.exe 解析——CodeBuddy 在 Windows 上的钩子运行器 +是 `%ComSpec%`(它通过 `child_process.spawn(command, [], { shell: true })` 执行钩子 +的 `command`),而任何 Windows 安装都提供 cmd.exe。只有无法解析出 shell 的工具才会 +被跳过。 --- @@ -80,7 +87,8 @@ export function hasShell(): boolean { 1. **裸 `bash` → WSL Node 18.** Windows 的 `PATH` 会把 `bash` 解析到 WSL 启动器, 而非 Git Bash;WSL 的 Node 18 无法解析 TeamAI 打包产物。 2. **`hasShell()` 的 Windows bug.** `fs.existsSync('/bin/sh')` 在 Windows 上永远为假, - 因此跳过 `codebuddy` / `workbuddy` 的钩子注入。 + 过去会跳过 `codebuddy` / `workbuddy` 的钩子注入;现在由按工具的 + `bundledShellFor()` 解析器覆盖它们。 3. **WSL 路径转换.** 在 WSL 侧用 `/mnt/c/...` 路径 `exec` Windows Node 的包装脚本会被 改写成 `C:\mnt\c\...`,导致 `MODULE_NOT_FOUND`。 @@ -173,9 +181,8 @@ dispatch (裸 bash/WSL): claude=0 codex=0 zcode=0 codebuddy=0 qoder=0 workbuddy 覆盖,机制 B 仅在 **WSL 保持安装** 时有效。若移除 WSL,裸 `bash` 钩子会再次失效。 - **机制 B 需要 WSL.** 在没有 WSL 的机器上,只有机制 A(当前配置文件中的 Git Bash 绝对路径)可用。 -- **不会修补 TeamAI 本身.** 上游的 `hasShell()` bug 与裸 `bash` 默认值并未在 - `teamai-cli` 内部修复。执行 `npm update teamai-cli` 后需重新应用本修复(或依赖 - WSL 包装脚本)。 +- **不会修补旧版 TeamAI.** 上游的 `hasShell()` 跳过与裸 `bash` 默认值已在当前 + `teamai-cli` 中修复;执行 `npm update teamai-cli` 即可获得,之后可移除本绕行方案。 - **macOS / Linux 无需修复.** 在这些系统上,裸 `bash` 已解析到系统 Node,原生可用。 - **`teamai doctor` 的 `gh` 检查可能是误报.** 它可能在没有 `APPDATA` 的情况下启动 `gh`,因此即使 `gh auth status` 显示已登录,它也看不到登录状态。若其他检查均通过, @@ -186,7 +193,8 @@ dispatch (裸 bash/WSL): claude=0 codex=0 zcode=0 codebuddy=0 qoder=0 workbuddy ## 给维护者的修复建议(上游) -两处小改动即可让 Windows 开箱即用: +两处小改动即可让 Windows 开箱即用——目前均已在 `teamai-cli` 中落地,本节保留作背景 +说明: ### 1. 让 `hasShell()` 感知 Windows @@ -218,7 +226,8 @@ export function hasShell(): boolean { } ``` -仅此一项改动即可让 `codebuddy` / `workbuddy` 的钩子在 Windows 上被注入。 +当前 `teamai` 通过 `hasShellFor()` → `bundledShellFor()` 实现了这一点,因此 +`codebuddy` / `workbuddy` 的钩子如今会在 Windows 上被注入。 ### 2. 在 Windows 上把 dispatch 命令默认指向 Git Bash 绝对路径 diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index 7957068f..b0c03aed 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'node:path'; import os from 'node:os'; +import { spawnSync } from 'node:child_process'; import fse from 'fs-extra'; vi.mock('../utils/logger.js', () => ({ @@ -406,11 +407,15 @@ hooks: await reconcileTeamHooksForConfig(codebuddyOnly, localConfig()); const [command] = await teamStopCommands('.codebuddy/settings.json'); - expect(command.startsWith('echo %CD%\\| findstr /i /b /l /c:"')).toBe(true); + // `cd` prints the cwd into the pipe — never `%CD%` interpolated into a + // parsed command — and the root is caret-escaped inside `^"…^"` quotes. + expect(command.startsWith('cd| findstr /i /b /l /c:^"')).toBe(true); + expect(command).toContain(' >nul || cd| findstr /i /e /l /c:^"'); // Outside the project the gate must exit 0 (a non-zero status would make // CodeBuddy treat UserPromptSubmit as allowed:false and block the prompt), // while the payload's own status is passed through inside it. - expect(command.endsWith('\\\\" >nul & if not errorlevel 1 (python3 .docs/script/inject-telemetry.py) else exit /b 0')).toBe(true); + expect(command.endsWith('^" >nul & if not errorlevel 1 (python3 .docs/script/inject-telemetry.py) else exit /b 0')).toBe(true); + expect(command).not.toContain('echo %CD%'); expect(command).not.toContain('&& (python3'); expect(command).not.toContain('$PWD'); } finally { @@ -432,3 +437,76 @@ hooks: } }); }); + +// ── The rendered cmd gate, executed by a real cmd.exe ──────── +// +// The assertions above pin only the shape of the gate. These run it in real +// directories whose names carry the characters cmd.exe re-parses — `&`, which +// otherwise executes the rest of the directory name, plus `^`, `%` and a space +// — because a gate that merely looks right can still run part of a path as a +// command or silently stop matching. Windows-only: cmd.exe is the point. +describe('project gate — real cmd.exe execution (win32)', () => { + const codebuddyOnly = { + toolPaths: { codebuddy: { settings: '.codebuddy/settings.json' } }, + } as unknown as TeamaiConfig; + + /** Render the gate for `root`, then run it from `cwd` through cmd.exe. */ + async function renderGate(root: string, sandboxHome: string): Promise { + await writeYaml(` +hooks: + - id: gate + description: gate probe + event: Stop + command: echo TEAMAI_GATE_PAYLOAD + tools: [codebuddy] +`); + await fse.ensureDir(path.join(sandboxHome, '.codebuddy')); + await reconcileTeamHooksForConfig(codebuddyOnly, { ...localConfig(), projectRoot: root } as LocalConfig); + const settings = await fse.readJson(path.join(sandboxHome, '.codebuddy', 'settings.json')); + const commands = (settings.hooks.Stop ?? []) + .filter((e: { description?: string }) => e.description?.startsWith('[teamai:hook:')) + .map((e: { hooks: Array<{ command: string }> }) => e.hooks[0].command); + expect(commands).toHaveLength(1); + return commands[0]; + } + + /** Run a rendered hook command the way CodeBuddy's hook runner does. */ + function runCommand(command: string, cwd: string): { status: number | null; stdout: string } { + const result = spawnSync(command, { cwd, shell: true, encoding: 'utf8' }); + return { status: result.status, stdout: result.stdout ?? '' }; + } + + it.skipIf(process.platform !== 'win32')( + 'fires only inside the project and never executes part of the path', + async () => { + for (const name of ['plain', 'sp&x', 'a^b', 'a%b', 'a%TEMP%b', 'sp ace', 'x&echo CANARY&y']) { + const root = path.join(project, name); + const sub = path.join(root, 'sub'); + const sibling = path.join(project, `${name}-sibling`); + await fse.ensureDir(sub); + await fse.ensureDir(sibling); + // A fresh HOME per project keeps the shared settings file free of the + // previous iteration's project-scoped entries. + const sandboxHome = path.join(project, 'home', name); + vi.stubEnv('HOME', sandboxHome); + const command = await renderGate(root, sandboxHome); + + for (const cwd of [root, sub]) { + const { status, stdout } = runCommand(command, cwd); + expect(stdout, `${name} inside ${cwd}`).toContain('TEAMAI_GATE_PAYLOAD'); + expect(status, `${name} inside ${cwd}`).toBe(0); + } + for (const cwd of [project, sibling]) { + const { status, stdout } = runCommand(command, cwd); + expect(stdout, `${name} outside ${cwd}`).not.toContain('TEAMAI_GATE_PAYLOAD'); + // A mismatch must stay an exit-0 no-op: CodeBuddy reads a non-zero + // hook status as allowed:false and would block every prompt typed + // outside the project. + expect(status, `${name} outside ${cwd}`).toBe(0); + } + // `&` in the directory name must never split the gate into commands. + expect(runCommand(command, root).stdout, `${name} injection canary`).not.toMatch(/^\s*CANARY\s*$/m); + } + }, + ); +}); diff --git a/src/hooks.ts b/src/hooks.ts index 90f821b9..bca8cb8c 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -279,17 +279,45 @@ function canonicalProjectRoot(projectRoot: string): string { try { return realpathSync.native(projectRoot); } catch { return path.resolve(projectRoot); } } +/** + * Embed a Windows path in a cmd.exe command line so the child receives it + * byte-for-byte. + * + * A path interpolated into cmd text is re-parsed: `%…%` expands and + * `& ^ ( ) | < >` act on the line even inside double quotes, so a project at + * `C:\src\x&whoami&` would run part of its own name every time a hook fires. + * Caret escapes stop that during cmd's parsing, but cmd consumes them before + * CreateProcess and the child then re-splits the line, where a caret cannot + * keep a space in one token. Emitting the quotes as `^"` covers both: cmd + * consumes the caret and hands over a real quote, so the value reaches the + * child literally and spaces stay inside one argument. + */ +function cmdLiteral(value: string): string { + return `^"${value.replace(/[%^&()<>|,;=]/g, (ch) => `^${ch}`)}^"`; +} + /** * cmd.exe equivalent of the POSIX project gate, as a prefix that resolves to - * true only inside `root`. Appending the separator makes the gate match the - * root itself and anything under it, while a sibling whose name merely shares - * the prefix (`C:\a\proj` vs `C:\a\proj-2`) does not. The pattern ends in `\\` - * because findstr's CRT argument parser consumes one backslash; `/l` keeps it - * literal, `/b` anchors it at the start of the line, and `/i` matches the - * case-insensitive Windows path. + * true only inside `root`. + * + * The cwd is read with a bare `cd`, whose output goes straight into the pipe: + * unlike `echo %CD%`, the directory name is never part of a parsed command, so + * `&`, `%` and `^` in it cannot be re-interpreted. `cd` prints no trailing + * separator, so the root itself needs its own end-anchored test. The + * separator-suffixed `/b` form covers everything below the root while a + * sibling that merely shares the prefix (`C:\a\proj` vs `C:\a\proj-2`) does + * not; `/e` accepts the root's own `C:\a\proj`, and a longer line ending in it + * is not a valid absolute Windows path. `/l` keeps the pattern literal and + * `/i` matches the case-insensitive Windows path. The pattern ends in `\\` + * because findstr's CRT argument parser consumes one backslash. */ function cmdProjectGate(root: string): string { - return `echo %CD%\\| findstr /i /b /l /c:"${root}\\\\" >nul`; + // A root that ends in a separator (a drive root, `C:\`) would end the quoted + // literal with a backslash and escape its closing quote, unbalancing the + // whole command line. Stripping it also leaves the `/b` form matching the + // drive root's own `C:\` cwd. + const literal = cmdLiteral(root.replace(/[\\/]+$/, '')); + return `cd| findstr /i /b /l /c:${literal}\\\\ >nul || cd| findstr /i /e /l /c:${literal} >nul`; } /** @@ -327,7 +355,7 @@ function isGatedForProject(command: string, projectRoot: string): boolean { } function isProjectGatedCommand(command: string): boolean { - return command.startsWith('if [ "$PWD" = ') || command.startsWith('echo %CD%\\| findstr '); + return command.startsWith('if [ "$PWD" = ') || command.startsWith('cd| findstr '); } function scopedTeamDefs(teamDefs: HookDef[], projectRoot: string | undefined, tool: string): HookDef[] {