Compare commits

..

2 Commits

Author SHA1 Message Date
Jesse Vincent
fd12428a5c docs(windows): document shell:bash hook dispatch and the PowerShell/CMD fallback hazards 2026-07-16 00:14:13 +00:00
Jesse Vincent
390bc07abb fix(hooks): dispatch the SessionStart hook via Git Bash on Windows
The SessionStart command string starts with a quoted path, which breaks
both Windows shells Claude Code may hand it to: PowerShell parses the
leading quoted string as an expression and dies on the next bareword
('Unexpected token session-start', #1751), and cmd.exe's /c quote rule
drops the outer quotes when the path contains a metacharacter, so a
profile dir like C:\Users\Name(External) truncates the command at the
'(' (#1918). Either way the bootstrap silently never loads.

Declare shell: "bash" on the hook. Claude Code >= 2.1.81 then resolves
Git for Windows and runs the polyglot's bash path directly — the same
route it already picks when it detects Git Bash — and when Git Bash is
missing it surfaces an actionable install prompt instead of a parser
error. Older versions ignore the unknown key and behave exactly as
before (verified live on 2.0.77 and 2.1.80).

Verified end-to-end with real claude sessions: Linux (hook fires,
bootstrap injected), Windows 11 + Git Bash under a path containing
'(' and a space (fires, 3276-char context), and Windows 11 without
Git Bash (actionable error replaces the #1751 ParserError, reproduced
verbatim as control).

Fixes #1751
Fixes #1918
2026-07-16 00:13:35 +00:00
8 changed files with 49 additions and 38 deletions

View File

@@ -6,9 +6,18 @@ Claude Code plugins need hooks that work on Windows, macOS, and Linux. This docu
## The Problem ## The Problem
Claude Code runs hook commands through the system's default shell: Claude Code runs hook commands through a shell:
- **Windows**: CMD.exe
- **macOS/Linux**: bash or sh - **macOS/Linux**: bash or sh
- **Windows with Git Bash installed**: Git Bash
- **Windows without Git Bash**: PowerShell (older versions used CMD.exe)
Neither Windows fallback shell can parse our command string: PowerShell treats
a leading quoted path as a string expression and errors on the next bareword,
and CMD.exe's `/c` quoting rules strip the outer quotes when the path contains
a metacharacter such as `(`. Our hooks therefore declare `"shell": "bash"`
(supported since Claude Code 2.1.81; older versions ignore the key), which
forces the Git Bash route and, when Git Bash is absent, produces an actionable
"install Git for Windows" error instead of a shell parser failure.
This creates several challenges: This creates several challenges:
@@ -42,6 +51,7 @@ hooks/
{ {
"type": "command", "type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start", "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
"shell": "bash",
"async": false "async": false
} }
] ]

View File

@@ -7,6 +7,7 @@
{ {
"type": "command", "type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start", "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
"shell": "bash",
"async": false "async": false
} }
] ]

View File

@@ -230,16 +230,14 @@ prepare_metadata_root() {
METADATA_ROOT="$(prepare_metadata_root "$METADATA_SOURCE")" METADATA_ROOT="$(prepare_metadata_root "$METADATA_SOURCE")"
# Pin tar.umask and extract with -p so staged modes are canonical 755/644 git -C "$REPO_ROOT" archive --format=tar "$REF" -- \
# regardless of the builder's git config or process umask.
git -C "$REPO_ROOT" -c tar.umask=0022 archive --format=tar "$REF" -- \
.codex-plugin \ .codex-plugin \
CODE_OF_CONDUCT.md \ CODE_OF_CONDUCT.md \
LICENSE \ LICENSE \
README.md \ README.md \
assets \ assets \
skills \ skills \
| tar -xpf - -C "$STAGE" | tar -xf - -C "$STAGE"
VERSION="$(jq -r '.version // empty' "$STAGE/.codex-plugin/plugin.json")" VERSION="$(jq -r '.version // empty' "$STAGE/.codex-plugin/plugin.json")"
[[ -n "$VERSION" ]] || die "could not read version from .codex-plugin/plugin.json" [[ -n "$VERSION" ]] || die "could not read version from .codex-plugin/plugin.json"
@@ -300,19 +298,12 @@ case "$FORMAT" in
) )
;; ;;
tar.gz) tar.gz)
# Match the prior official archive's deterministic tar entry metadata: # Match the prior official archive's deterministic tar entry metadata.
# ustar entries with uid/gid 0 and empty uname/gname. GNU tar and bsdtar
# (macOS) spell those flags differently.
if tar --version 2>/dev/null | grep -q 'GNU tar'; then
TAR_METADATA_FLAGS=(--owner=:0 --group=:0 --numeric-owner)
else
TAR_METADATA_FLAGS=(--uid 0 --gid 0 --uname '' --gname '')
fi
TZ=UTC find "$STAGE" -exec touch -t 197001010000 {} + TZ=UTC find "$STAGE" -exec touch -t 197001010000 {} +
( (
cd "$STAGE" cd "$STAGE"
rm -f "$OUTPUT" rm -f "$OUTPUT"
COPYFILE_DISABLE=1 tar -cf - --no-recursion --format ustar "${TAR_METADATA_FLAGS[@]}" -T "$ARCHIVE_LIST" | COPYFILE_DISABLE=1 tar -cf - --no-recursion --format ustar --uid 0 --gid 0 --uname '' --gname '' -T "$ARCHIVE_LIST" |
gzip -9n >"$OUTPUT" gzip -9n >"$OUTPUT"
) )
;; ;;

View File

@@ -25,8 +25,7 @@ fi
# Parse command line arguments # Parse command line arguments
VERBOSE=false VERBOSE=false
SPECIFIC_TEST="" SPECIFIC_TEST=""
TIMEOUT=900 # Per-test-file budget; must exceed the file's worst case TIMEOUT=600 # Default 10 minute timeout per test
# (test-subagent-driven-development.sh: 9 prompts x 90s each)
RUN_INTEGRATION=false RUN_INTEGRATION=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -53,7 +52,7 @@ while [[ $# -gt 0 ]]; do
echo "Options:" echo "Options:"
echo " --verbose, -v Show verbose output" echo " --verbose, -v Show verbose output"
echo " --test, -t NAME Run only the specified test" echo " --test, -t NAME Run only the specified test"
echo " --timeout SECONDS Set timeout per test (default: 900)" echo " --timeout SECONDS Set timeout per test (default: 300)"
echo " --integration, -i Run integration tests (slow, 10-30 min)" echo " --integration, -i Run integration tests (slow, 10-30 min)"
echo " --help, -h Show this help" echo " --help, -h Show this help"
echo "" echo ""

View File

@@ -30,14 +30,12 @@ run_claude() {
# Check if output contains a pattern # Check if output contains a pattern
# Usage: assert_contains "output" "pattern" "test name" # Usage: assert_contains "output" "pattern" "test name"
# Matching is case-insensitive: patterns are prose keywords, and models
# freely capitalize skill terms ("Do Not Trust", "Spec Compliance").
assert_contains() { assert_contains() {
local output="$1" local output="$1"
local pattern="$2" local pattern="$2"
local test_name="${3:-test}" local test_name="${3:-test}"
if echo "$output" | grep -qi "$pattern"; then if echo "$output" | grep -q "$pattern"; then
echo " [PASS] $test_name" echo " [PASS] $test_name"
return 0 return 0
else else
@@ -56,7 +54,7 @@ assert_not_contains() {
local pattern="$2" local pattern="$2"
local test_name="${3:-test}" local test_name="${3:-test}"
if echo "$output" | grep -qi "$pattern"; then if echo "$output" | grep -q "$pattern"; then
echo " [FAIL] $test_name" echo " [FAIL] $test_name"
echo " Did not expect to find: $pattern" echo " Did not expect to find: $pattern"
echo " In output:" echo " In output:"
@@ -76,7 +74,7 @@ assert_count() {
local expected="$3" local expected="$3"
local test_name="${4:-test}" local test_name="${4:-test}"
local actual=$(echo "$output" | grep -ci "$pattern" || echo "0") local actual=$(echo "$output" | grep -c "$pattern" || echo "0")
if [ "$actual" -eq "$expected" ]; then if [ "$actual" -eq "$expected" ]; then
echo " [PASS] $test_name (found $actual instances)" echo " [PASS] $test_name (found $actual instances)"
@@ -100,20 +98,16 @@ assert_order() {
local test_name="${4:-test}" local test_name="${4:-test}"
# Get line numbers where patterns appear # Get line numbers where patterns appear
local line_a=$(echo "$output" | grep -ni "$pattern_a" | head -1 | cut -d: -f1) local line_a=$(echo "$output" | grep -n "$pattern_a" | head -1 | cut -d: -f1)
local line_b=$(echo "$output" | grep -ni "$pattern_b" | head -1 | cut -d: -f1) local line_b=$(echo "$output" | grep -n "$pattern_b" | head -1 | cut -d: -f1)
if [ -z "$line_a" ]; then if [ -z "$line_a" ]; then
echo " [FAIL] $test_name: pattern A not found: $pattern_a" echo " [FAIL] $test_name: pattern A not found: $pattern_a"
echo " In output:"
echo "$output" | sed 's/^/ /'
return 1 return 1
fi fi
if [ -z "$line_b" ]; then if [ -z "$line_b" ]; then
echo " [FAIL] $test_name: pattern B not found: $pattern_b" echo " [FAIL] $test_name: pattern B not found: $pattern_b"
echo " In output:"
echo "$output" | sed 's/^/ /'
return 1 return 1
fi fi

View File

@@ -96,13 +96,13 @@ echo "Test 5: Spec compliance reviewer mindset..."
output=$(run_claude "What is the spec compliance reviewer's attitude toward the implementer's report in subagent-driven-development?" "$CLAUDE_PROMPT_TIMEOUT") output=$(run_claude "What is the spec compliance reviewer's attitude toward the implementer's report in subagent-driven-development?" "$CLAUDE_PROMPT_TIMEOUT")
if assert_contains "$output" "not.*trust\|don't trust\|skeptical\|verify.*independently\|suspiciously" "Reviewer is skeptical"; then if assert_contains "$output" "not trust\|don't trust\|skeptical\|verify.*independently\|suspiciously" "Reviewer is skeptical"; then
: # pass : # pass
else else
exit 1 exit 1
fi fi
if assert_contains "$output" "read.*code\|inspect.*code\|verify.*code\|read.*diff\|trust.*diff" "Reviewer reads code"; then if assert_contains "$output" "read.*code\|inspect.*code\|verify.*code" "Reviewer reads code"; then
: # pass : # pass
else else
exit 1 exit 1

View File

@@ -210,13 +210,8 @@ assert_equals "$tar_archive_paths" "$archive_paths" "zip and tar.gz archives con
tar_task_brief_mode="$(tar -tzvf "$tar_archive" skills/subagent-driven-development/scripts/task-brief | awk '{print $1}')" tar_task_brief_mode="$(tar -tzvf "$tar_archive" skills/subagent-driven-development/scripts/task-brief | awk '{print $1}')"
assert_equals "$tar_task_brief_mode" "-rwxr-xr-x" "tar.gz archive preserves executable script mode" assert_equals "$tar_task_brief_mode" "-rwxr-xr-x" "tar.gz archive preserves executable script mode"
tar_metadata_times="$(python3 - "$tar_archive" <<'PY' tar_metadata_times="$(tar -tzvf "$tar_archive" | awk '{print $6, $7, $8}' | sort -u)"
import sys, tarfile assert_equals "$tar_metadata_times" "Dec 31 1969" "tar.gz archive normalizes entry timestamps"
with tarfile.open(sys.argv[1]) as archive:
print(sorted({member.mtime for member in archive.getmembers()}))
PY
)"
assert_equals "$tar_metadata_times" "[0]" "tar.gz archive normalizes entry timestamps"
metadata_archive="$TEST_ROOT/metadata-source.tar.gz" metadata_archive="$TEST_ROOT/metadata-source.tar.gz"
metadata_zip="$TEST_ROOT/metadata-source.zip" metadata_zip="$TEST_ROOT/metadata-source.zip"

View File

@@ -143,6 +143,27 @@ for (const forbiddenText of forbiddenTexts) {
echo "SessionStart hook output tests" echo "SessionStart hook output tests"
# Registration shape: the hook must declare shell:"bash" so Claude Code on
# Windows dispatches via Git Bash (or fails with an actionable error) instead
# of PowerShell/cmd.exe, whose parsers break on the quoted command string
# (PowerShell ParserError; cmd.exe quote-stripping on paths with metacharacters).
if node -e '
const hooks = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const entry = hooks.hooks.SessionStart[0].hooks[0];
if (entry.shell !== "bash") {
console.error(`SessionStart hook shell is ${JSON.stringify(entry.shell)}, expected "bash"`);
process.exit(1);
}
if (!/run-hook\.cmd" session-start$/.test(entry.command)) {
console.error(`unexpected SessionStart command shape: ${entry.command}`);
process.exit(1);
}
' "$REPO_ROOT/hooks/hooks.json"; then
pass "hooks.json registers SessionStart with shell:bash dispatch"
else
fail "hooks.json registers SessionStart with shell:bash dispatch"
fi
claude_home="$(make_home claude-code)" claude_home="$(make_home claude-code)"
assert_command_output \ assert_command_output \
"Claude Code emits nested SessionStart additionalContext" \ "Claude Code emits nested SessionStart additionalContext" \