Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion plugins/warp/scripts/build-payload.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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" \
Expand Down
24 changes: 20 additions & 4 deletions plugins/warp/scripts/emit-terminal-sequence.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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=""
Expand All @@ -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}'
}
8 changes: 6 additions & 2 deletions plugins/warp/scripts/legacy/warp-notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ TITLE="${1:-Notification}"
BODY="${2:-}"

# OSC 777 format: \033]777;notify;<title>;<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
77 changes: 77 additions & 0 deletions plugins/warp/tests/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down