fix(hooks): invoke viz hooks in shell form so Windows uses Git Bash (#27) - #29
Open
ZayanKhan-12 wants to merge 3 commits into
Open
ZayanKhan-12 wants to merge 3 commits into
ZayanKhan-12 wants to merge 3 commits into
Conversation
…atadog-labs#27) The SessionEnd hook failed on every exit on Windows, printing: SessionEnd hook [bash ${CLAUDE_PLUGIN_ROOT}/viz/hooks/session_end.sh] failed: Hook cancelled Passing `args` puts a hook in exec form, where Claude Code resolves `command` against PATH and spawns it with no shell. On a default Windows install `bash` on PATH is C:\Windows\System32\bash.exe -- the WSL launcher, not Git Bash. It consumes the Windows ${CLAUDE_PLUGIN_ROOT} as escape sequences, so the script is never found and the hook fails even though its only job off macOS is to do nothing. Drop `args` and name the script in the command string instead. Shell form hands that string to `sh -c` on macOS and Linux and to Git Bash on Windows, which resolves the path correctly. Shell form executes the file directly, so both scripts become mode 755; at 644 they would exit 126. This covers forward.sh too. It had the identical exec-form invocation and is just as broken on Windows -- it only escaped notice because it fires on visualization tool calls rather than on every exit. Unlike session_end.sh it has real work to do off macOS (telling the model the chart is at the sandbox URL), so it has to keep working, not stop being registered. The issue also asks for the hook not to be registered off macOS. That is not expressible: `if` takes permission-rule syntax and is only evaluated on tool events, so on SessionEnd a hook with `if` set never runs at all. A macOS-only hook can only be made cheap and silent, which it now is. tests/hooks_test.sh covers the wiring: hooks.json parses, no hook uses a bare interpreter in exec form, every referenced script exists and is executable, and both hooks exit 0 and stay silent with `uname` stubbed to MINGW64_NT, MSYS_NT, CYGWIN_NT and Linux. Against the pre-fix config it fails only the exec-form check -- macOS was never broken, and the suite says so rather than manufacturing a failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UdFDRJ6HXM45hua8JnXomE
datadog-labs#27 is a Windows-only failure, so the suite is only worth much if it actually runs there. The matrix job covers all three platforms; the windows-latest job runs it under Git Bash, which is the shell Claude Code uses for shell-form hooks on Windows. A second Windows job injects CLAUDE_PLUGIN_ROOT as ${{ github.workspace }}, a native backslash path. Git Bash reports $PWD in Unix form, so without this the Windows job would never see the path shape that caused the bug -- a Windows path reaching a hook is the whole failure mode. Scoped by paths so it only fires on hook, viz or test changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UdFDRJ6HXM45hua8JnXomE
The first CI run went red on both Windows jobs while every behavioural
check passed. The failures were in the harness, not the hooks.
Two problems, both in how the suite recovered a script path from the
command string:
sed treats backslashes in the *replacement* as escapes, so substituting a
native ${CLAUDE_PLUGIN_ROOT} of D:\a\... produced D:^G^Laude-code-plugin
-- \a became BEL. That is the same class of bug as datadog-labs#27 itself, reproduced
inside the test written to catch it. Bash parameter expansion substitutes
literally, so use that.
The existence and mode checks were also resolving against the injected
plugin root, which conflates two different questions. Whether a Windows
path survives the hop into the hook is what the behavioural runs cover;
these two checks are about the files. Resolve them against the checkout
instead, and take the mode from `git ls-files -s` rather than a working
tree -x test: git does not materialise permission bits on Windows, so -x
says nothing there, while the recorded mode is what actually ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdFDRJ6HXM45hua8JnXomE
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #27.
The fix
hooks/hooks.json, two lines:Passing
argsis what puts a hook in exec form, where Claude Code resolvescommandagainstPATHand spawns it directly with no shell. That is why barebashbecomes the WSL launcher on Windows, exactly as the report diagnosed.Dropping
argsswitches to shell form, where the string goes tosh -con macOS and Linux and to Git Bash on Windows — which resolves the plugin path correctly. This is the form the plugin docs show for${CLAUDE_PLUGIN_ROOT}script hooks.Shell form executes the file itself rather than handing it to an interpreter, so both scripts go to mode
755. At644they exit126with "Permission denied" — verified, not assumed.forward.shhas the same bugThe report is about
SessionEnd, butPostToolUsecarried the byte-identical exec-form invocation and is just as broken on Windows. It only escaped notice because it fires on visualization tool calls rather than on every exit.It needed the opposite treatment from
session_end.sh, though. Off macOSforward.shis not a no-op — it callsbail unsupported_os, which is what tells the model the chart is available at the sandbox URL. So it has to keep working on Windows, not stop being registered. Both hooks are fixed the same way; onlysession_end.shis a true no-op.On "registered unconditionally"
I could not fix cause 1 as described, and I think the premise doesn't hold — flagging it rather than quietly skipping it.
There is no OS conditional for hook registration. The
iffield takes permission-rule syntax and, per the hooks reference, "Only evaluated on tool events:PreToolUse,PostToolUse,PostToolUseFailure,PermissionRequest, andPermissionDenied. On other events, a hook withifset never runs." PuttingifonSessionEndwould disable the hook on macOS too — the one platform it exists for.So a macOS-only hook can only be made cheap and silent, not unregistered. With cause 2 fixed it is: the shell starts,
unamemissesDarwin, the script exits0with no output. Cause 1 stops being a bug once cause 2 is fixed. If you'd rather not spawn a shell at all on non-macOS, that needs a Claude Code-side feature.Verified on Windows
.github/workflows/hooks.yamlruns the suite on macOS, Linux and Windows. All four jobs green.The job that matters is
test (windows-latest, native plugin root). Git Bash reports$PWDin Unix form, so an ordinary Windows job would never see the path shape that caused this bug. That job injectsCLAUDE_PLUGIN_ROOTas GitHub's native workspace path instead, so the hook receives a realD:\...root:Full run: https://github.com/ZayanKhan-12/claude-code-plugin/actions/runs/34900090384
Worth reporting: the first Windows run went red, and it caught a real bug — in the test harness, while every behavioural check already passed. The harness recovered the script path with
sed, andsedtreats backslashes in the replacement as escapes, soD:\a\...becameD:<BEL>.... That is the same class of bug as #27 itself, reproduced inside the test written to catch it. Fixed in the third commit by substituting with bash parameter expansion, and by resolving the file checks against the checkout rather than the injected root — path shape is what the behavioural runs are for. The mode assertion now readsgit ls-files -sinstead of testing-x, because git doesn't materialise permission bits on Windows, so-xsays nothing there while the recorded mode is what actually ships.Tests
tests/hooks_test.sh— POSIX-ish bash,jqthe only dependency, no network, never opens the ddviz socket, runs withDO_NOT_TRACK=1so a local run can't post to intake.Run against the pre-fix config it reports
11 passed, 1 failed, and the one failure is the exec-form check. That is deliberate. The harness understands both hook forms, so it invokes the old config the way Claude Code actually would; macOS was never broken and the suite says so instead of manufacturing a failure. The single red line is the real defect.CLAUDE.md
Added, since there wasn't one. The part that matters is the hook rule above — shell form, mode
755, and whyifcan't gateSessionEnd— so this doesn't get reintroduced. It also records what I had to work out from the code and would have wanted stated:forward.sh's off-macOSbailis load-bearing, not dead code; don't "optimize" it into an early exit.forward.sh'stemplate.ddviz_telemetry.shfallback is live in the internal source tree, so it looks dead here but isn't.SKILL.mdruns them through the Bash tool, which already uses Git Bash on Windows. They correctly keepbash "<path>"and mode644, and this change deliberately leaves them alone.DO_NOT_TRACK=1belongs in anything exercising these scripts.Noted from CONTRIBUTING that this repo is regenerated from an internal source, so I kept the fix itself to two lines and a mode change. The tests, CI and CLAUDE.md are separable — happy to split them into their own PR if that's easier to carry across, or drop the CI workflow if you'd rather own that yourself.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UdFDRJ6HXM45hua8JnXomE