Skip to content

fix(hooks): render CodeBuddy's Windows hooks in POSIX, not cmd.exe - #989

Open
CarlosWonMore wants to merge 1 commit into
Tencent:mainfrom
CarlosWonMore:fix/codebuddy-hooks-posix
Open

CarlosWonMore wants to merge 1 commit into
Tencent:mainfrom
CarlosWonMore:fix/codebuddy-hooks-posix

Conversation

@CarlosWonMore

Copy link
Copy Markdown
Contributor

Summary

CodeBuddy runs its Windows hook commands through Git Bash, but teamai rendered them in cmd.exe syntax. Run by Git Bash, 2>nul is a file redirect, not the null device, so every hook invocation created a real 0-byte file named nul in the hook's working directory — and because the PostToolUse wildcard hook fires after every tool call, the project root got one continuously. The same rendering also made the PATH prepend a no-op and turned the deliberate "always exit 0" || exit /b 0 into an exit 2, which is exactly what makes CodeBuddy read a hook as allowed:false.

This makes CodeBuddy use the POSIX renderings — built-in dispatch and the team-hook project gate — like every other tool, and resolves its shell to Git Bash.

Supersedes #933 (closed unmerged). That attempt carried the same fix but was never merged; this rebases it onto the current main (3f11504) and resolves the conflicts. The two cmd-related files it could not touch cleanly then — the core src/bundled-runtime.ts — were untouched upstream as well, and are still conflict-free here.

Fixes #932.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change)
  • Documentation only
  • Refactor / internal cleanup

What changed

Drop the cmd.exe renderer. toolUsesCmdShell() and the cmd branch of gateTeamHookCommand() are gone; every tool now gets the POSIX gate. CodeBuddy resolves its hook shell through the Git Bash it already requires on Windows (resolveCodebuddyShell() → findGitBashWindows()), returning null — which correctly skips injection with the existing warning — when Git Bash is absent, instead of installing hooks that could never run.

Keep the legacy cmd gate readable. cmdProjectGate() stays as a read-side recogniser in isGatedForProject(): entries written by 0.26.0 are still on disk, and matching them is what lets a re-render replace the old gate rather than stack a second one on top.

Preserve the Cursor/Copilot wrapper. skipWhenAnotherHostLoadsClaudeSettings() (#950) is orthogonal and still keyed on tool; only the gate lost its per-tool form. Resolving the conflict kept it intact.

Test Plan

  • npx tsc --noEmit — passes
  • npm run lint — 0 warnings, 0 errors on 811 files. Note: --type-aware (tsgolint) could not run in my sandbox — its subprocess fails to spawn with os error 231 (all pipe instances busy), which is an environment limit rather than a code signal, so that half is unverified here.
  • npx vitest run on the 4 test files touching this area — 66 passed, 3 skipped, 0 failed (hooks-golden, hooks-shell-check, hooks-windows-bash, hooks-reconcile-scope). The full suite is not a usable gate on Windows; unrelated pre-existing /bin/sh, chmod and file-mode cases fail identically on main.
  • Added/updated tests for the change

Environment-independent gate tests

hooks-reconcile-scope.test.ts asserted the rendered gate by reading back ~/.codebuddy/settings.json, but it never staged a shell for the mocked win32 platform — so whether the case ran at all depended on the developer's Git install location. On a machine where Git lives outside ProgramFiles/LOCALAPPDATA and can only be found via the HKLM\SOFTWARE\GitForWindows registry key, findGitBashWindows() returned null, skipToolsWithoutShell() dropped both tools, and the read failed with ENOENT.

The block now clears the three environment variables and stages both candidates under the mocked home (CodeBuddy's bash.exe, WorkBuddy's PortableGit sh.exe), matching what hooks-shell-check.test.ts already does. The gate assertions are now the same on any developer box and on CI.

Real-CLI end-to-end verification

Built with npm run build, then ran the built CLI against a throwaway HOME (a temp dir) with an already-initialised config and a local team repo, so the real injection path ran:

$ HOME=<sandbox> node dist/index.js hooks inject
⚠ Skipping hook injection for workbuddy: no shell is available in this environment to execute hooks. …
✔ Updated teamai hooks in <sandbox>\.codebuddy\settings.json
✔ Hooks injected into all AI tool settings

codebuddy is no longer skipped (its Git Bash resolves), and <sandbox>\.codebuddy\settings.json now holds:

"command": "PATH=\"$HOME/.teamai/bin:$PATH\" teamai hook-dispatch post-tool-use --tool codebuddy 2>/dev/null || true"

Before this change the same run produced (captured from a real Windows machine, and pinned by the old assertions):

"command": "set \"PATH=%HOME%/.teamai/bin;%PATH%\" && teamai hook-dispatch post-tool-use --tool codebuddy 2>nul || exit /b 0"

2>nul under Git Bash creates a file named nul; set "PATH=…;%PATH%" never prepends anything; || exit /b 0 exits 2 on a failing dispatch, which CodeBuddy reads as allowed:false.

The project gate moved the same way — from

cd| findstr /b /i "C:\\proj" >nul && (python3 .docs/script/inject-telemetry.py) || exit /b 0

to

if [ "$PWD" = 'C:\proj' ] || case "$PWD" in 'C:\proj'/*) true;; *) false;; esac; then (python3 .docs/script/inject-telemetry.py); fi

Notes for review

  • The gate's exit-status contract is unchanged: outside the project it is a no-op exiting 0, inside it the payload's status passes through. A mismatch returning non-zero would make CodeBuddy read the hook as allowed:false and block every UserPromptSubmit outside the project.
  • The cmd form was deliberately not ${gate} || exit /b 0 && (…): the || would swallow a genuine payload failure along with the mismatch. With only one rendering left, the POSIX if …; then …; fi gives that pass-through directly.

CodeBuddy runs a hook's `command` string through Git Bash on Windows — it
dropped PowerShell/CMD for hooks in 2.52.4 and forces Git Bash since 2.55.1 —
so the cmd-syntax commands added in Tencent#637 misbehave there:

- `2>nul` is a file redirect for bash, not the null device, so every hook
  invocation leaves a 0-byte file named `nul` in the hook's working directory.
  PostToolUse (matcher `*`) fires after every tool call, so it reappears
  continuously in the project root, and a reserved device name cannot be
  removed with `del` or Explorer.
- `set "PATH=…;%PATH%"` never prepends PATH under bash.
- `|| exit /b 0` makes bash exit 2 on a failing dispatch, which CodeBuddy
  reads as `allowed:false` and would block a UserPromptSubmit with.

The project gate is affected the same way: `cd| findstr … >nul` run by Git
Bash errors on `findstr`, exits non-zero and leaves another `nul`.

Render the POSIX wrapper form for CodeBuddy, which leaves the cmd renderer
unused: drop it, and resolve CodeBuddy's shell to Git Bash the way WorkBuddy's
resolver already asks for its bundled MSYS sh. Null when Git Bash is absent,
which skips injection with the existing warning instead of installing hooks
that could never run. Legacy cmd gates stay recognised on read, so entries
written by 0.26.0 are replaced on the next pull rather than duplicated.
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
  • [P1 blocking] src/hooks.ts:420 compares Git Bash’s $PWD with a native Windows path. For a project rooted at C:\proj, canonicalProjectRoot() renders C:\proj, while Git Bash exposes $PWD as an MSYS path such as /c/proj. The condition therefore remains false even inside the project, so project-scoped CodeBuddy team hooks never execute. Convert the root to Git Bash/MSYS form before rendering the POSIX gate, and cover this with an actual Git Bash execution test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] CodeBuddy hooks are rendered in cmd.exe syntax and leave a junk nul file on every tool call

2 participants