diff --git a/plugins/warp/scripts/build-payload.sh b/plugins/warp/scripts/build-payload.sh index 9ad610e..b0c8523 100644 --- a/plugins/warp/scripts/build-payload.sh +++ b/plugins/warp/scripts/build-payload.sh @@ -35,6 +35,11 @@ build_payload() { local protocol_version protocol_version=$(negotiate_protocol_version) + # Sourced by the hook scripts, so `return` (not `exit`) when jq is missing; + # callers treat an empty body as "no notification to send" rather than + # surfacing "jq: command not found" as a hook error. + command -v jq &>/dev/null || return 0 + # Extract common fields from the hook input local session_id cwd project session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null) @@ -46,7 +51,12 @@ build_payload() { # Build the payload: common fields + any extra args passed by the caller. # Extra args should be jq flag pairs like: --arg key "value" or --argjson key '{"a":1}' - jq -nc \ + # + # MSYS2_ARG_CONV_EXCL stops Git Bash rewriting path-like arguments into + # Windows paths, which corrupted cwd and transcript_path (e.g. + # "/Users/alice/p" -> "D:/.../Users/alice/p"). jq only sees opaque strings. + # Ignored on Linux/macOS. + MSYS2_ARG_CONV_EXCL='*' jq -nc \ --argjson v "$protocol_version" \ --arg agent "claude" \ --arg event "$event" \ diff --git a/plugins/warp/scripts/emit-terminal-sequence.sh b/plugins/warp/scripts/emit-terminal-sequence.sh index b6a83b9..7c45f7d 100644 --- a/plugins/warp/scripts/emit-terminal-sequence.sh +++ b/plugins/warp/scripts/emit-terminal-sequence.sh @@ -20,10 +20,22 @@ # When the sequence is delivered via /dev/tty (side effect), nothing is printed # to stdout. When it must go through terminalSequence, a JSON object is printed # to stdout — the caller should ensure this reaches the hook's stdout. +# +# On Windows Git Bash / MSYS2 a /dev/tty node exists but opening it fails with +# ENXIO when the process has no controlling terminal, so the node's presence +# says nothing about whether it is usable. # The first Claude Code version that supports the terminalSequence output field. TERMINAL_SEQUENCE_MIN_VERSION="2.1.141" +# Write $1 to the controlling terminal; returns 1 if there isn't a usable one. +# stderr is redirected *before* the open: in `> /dev/tty 2>/dev/null` the open +# runs while stderr is still inherited, so a failure would escape. Probing and +# writing in one step also avoids a check-then-use gap if the tty goes away. +_tty_write() { + printf '%s' "$1" 2>/dev/null > /dev/tty +} + # Compare two dotted version strings (e.g. "2.1.141" >= "2.1.141"). # Returns 0 (true) if $1 >= $2, 1 (false) otherwise. _version_at_least() { @@ -59,6 +71,10 @@ emit_terminal_sequence() { local seq="$1" [ -z "$seq" ] && return 0 + # Without jq the JSON fallback is impossible, so stay silent rather than + # leaking "jq: command not found" as a hook error. + command -v jq &>/dev/null || return 0 + # Classify the running Claude Code version, if we can. local raw="${CLAUDE_CODE_VERSION:-}" local ver="" @@ -67,20 +83,20 @@ emit_terminal_sequence() { if [ -n "$ver" ]; then if _version_at_least "$ver" "$TERMINAL_SEQUENCE_MIN_VERSION"; then # Known new Claude Code — use the structured output field. - jq -nc --arg seq "$seq" '{terminalSequence: $seq}' + MSYS2_ARG_CONV_EXCL='*' jq -nc --arg seq "$seq" '{terminalSequence: $seq}' else # Known-old Claude Code — /dev/tty is the only safe path. # Emitting terminalSequence here would be rejected by the Stop # hook validator as an unknown field. - printf '%s' "$seq" > /dev/tty 2>/dev/null || true + _tty_write "$seq" || true fi return 0 fi # Unknown Claude Code version — try /dev/tty, fall back to JSON # as a best-effort attempt for new CC without version detection. - if printf '%s' "$seq" > /dev/tty 2>/dev/null; then + if _tty_write "$seq"; then return 0 fi - jq -nc --arg seq "$seq" '{terminalSequence: $seq}' + MSYS2_ARG_CONV_EXCL='*' jq -nc --arg seq "$seq" '{terminalSequence: $seq}' } diff --git a/plugins/warp/scripts/legacy/warp-notify.sh b/plugins/warp/scripts/legacy/warp-notify.sh index 6ca0588..cc9f97e 100755 --- a/plugins/warp/scripts/legacy/warp-notify.sh +++ b/plugins/warp/scripts/legacy/warp-notify.sh @@ -6,5 +6,9 @@ TITLE="${1:-Notification}" BODY="${2:-}" # OSC 777 format: \033]777;notify;;<body>\007 -# Write directly to /dev/tty to ensure it reaches the terminal -printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > /dev/tty 2>/dev/null || true +# Write to /dev/tty when one is usable. MSYS2 has a /dev/tty node that cannot be +# opened on a detached process, so probe first; stderr is redirected before the +# open (left-to-right) so the ENXIO error cannot leak either way. +if [ -e /dev/tty ] && { : > /dev/tty; } 2>/dev/null; then + printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" 2>/dev/null > /dev/tty || true +fi diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh index 754bdd0..65d652e 100755 --- a/plugins/warp/tests/test-hooks.sh +++ b/plugins/warp/tests/test-hooks.sh @@ -287,6 +287,83 @@ for HOOK in on-permission-request.sh on-prompt-submit.sh on-post-tool-use.sh; do assert_eq "$HOOK exits 0 without protocol version" "0" "$?" done +echo "" +echo "--- Windows: MSYS2 must not rewrite payload paths ---" + +# Git Bash rewrites path-like arguments before the child process sees them, so +# jq received mangled values. Both the extracted cwd and caller --arg paths +# must survive. +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/Users/alice/my-project"}' "stop") +assert_json_field "cwd not rewritten" "$PAYLOAD" ".cwd" "/Users/alice/my-project" + +PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/Users/alice/p"}' "stop" \ + --arg transcript_path "/tmp/transcript.jsonl") +assert_json_field "transcript_path not rewritten" \ + "$PAYLOAD" ".transcript_path" "/tmp/transcript.jsonl" + +echo "" +echo "--- Windows: detached hooks emit nothing on stderr ---" + +# No controlling terminal on this process, so a /dev/tty write fails with ENXIO. +# Reproduced by piping stdin and capturing stderr; the hooks must stay silent. +NO_TTY_INPUT='{"session_id":"s1","cwd":"/Users/alice/p","stop_hook_active":false,"error":"rate_limit","last_assistant_message":"e","notification_type":"idle_prompt","message":"m","prompt":"p","tool_name":"Bash","tool_input":{"command":"ls"}}' +ERR_FILE="$(mktemp)" + +for HOOK in on-stop.sh on-stop-failure.sh on-notification.sh on-session-start.sh \ + on-permission-request.sh on-prompt-submit.sh on-post-tool-use.sh; do + : > "$ERR_FILE" + echo "$NO_TTY_INPUT" | \ + WARP_CLI_AGENT_PROTOCOL_VERSION=1 \ + WARP_CLIENT_VERSION=v0.2026.04.01.stable_01 \ + bash "$HOOK_DIR/$HOOK" >/dev/null 2>"$ERR_FILE" + assert_eq "$HOOK: stderr empty" "0" "$(wc -c < "$ERR_FILE" | tr -d ' ')" +done + +# All three version-dispatch branches of emit_terminal_sequence, no tty. +for CASE in "unknown:" "old:2.1.100" "new:2.1.141"; do + : > "$ERR_FILE" + CLAUDE_CODE_VERSION="${CASE#*:}" bash -c \ + "source '$SCRIPT_DIR/emit-terminal-sequence.sh'; emit_terminal_sequence 'SEQ'" \ + >/dev/null 2>"$ERR_FILE" + assert_eq "${CASE%%:**} version: stderr empty" "0" "$(wc -c < "$ERR_FILE" | tr -d ' ')" +done + +# The write must stay silent when it fails, and must report failure so the +# caller can fall back to the JSON field. +: > "$ERR_FILE" +{ _tty_write "seq"; } >/dev/null 2>"$ERR_FILE" || true +assert_eq "_tty_write: stderr empty on failed write" "0" "$(wc -c < "$ERR_FILE" | tr -d ' ')" + +# Control: the old unguarded pattern (stderr inherits the terminal) must still +# leak where no usable tty exists, otherwise the assertion above is vacuous. +if [ -e /dev/tty ] && ! _tty_write ""; then + assert_eq "control: unguarded write still leaks (test is meaningful)" "nonzero" \ + "$({ printf 'x' > /dev/tty; } 2>"$ERR_FILE"; \ + if [ "$(wc -c < "$ERR_FILE" | tr -d ' ')" -gt 0 ]; then echo nonzero; else echo zero; fi)" +fi + +rm -f "$ERR_FILE" + +echo "" +echo "--- Missing jq degrades silently ---" + +# jq is required by build_payload and emit_terminal_sequence; without it both +# must stay quiet rather than emitting "jq: command not found" as a hook error. +NO_JQ_PATH="/usr/bin:/bin" +ERR_FILE="$(mktemp)" + +: > "$ERR_FILE" +BODY=$(PATH="$NO_JQ_PATH" bash -c "source '$SCRIPT_DIR/build-payload.sh'; build_payload '{\"cwd\":\"/tmp/x\"}' stop" 2>"$ERR_FILE") +assert_eq "build_payload without jq: stderr empty" "0" "$(wc -c < "$ERR_FILE" | tr -d ' ')" +assert_eq "build_payload without jq: empty body" "" "$BODY" + +: > "$ERR_FILE" +PATH="$NO_JQ_PATH" bash -c \ + "source '$SCRIPT_DIR/emit-terminal-sequence.sh'; CLAUDE_CODE_VERSION=2.1.141 emit_terminal_sequence 'SEQ'" \ + >/dev/null 2>"$ERR_FILE" +assert_eq "emit_terminal_sequence without jq: stderr empty" "0" "$(wc -c < "$ERR_FILE" | tr -d ' ')" +rm -f "$ERR_FILE" + # --- Summary --- echo ""