diff --git a/plugins/warp/scripts/find-controlling-tty.sh b/plugins/warp/scripts/find-controlling-tty.sh new file mode 100755 index 0000000..3a12d6d --- /dev/null +++ b/plugins/warp/scripts/find-controlling-tty.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Emit candidate controlling-tty paths by walking up the process tree. +# +# Background: when the hook runs inside a sandbox that detaches the subprocess +# from its controlling terminal (e.g. Claude Code's macOS `sandbox-exec` +# wrapper for Bash tool calls, when `sandbox.enabled` is set), the hook has +# no controlling terminal — opening `/dev/tty` returns ENXIO. To still deliver +# the OSC notification, we walk up the process tree and emit each ancestor's +# tty device path; the caller tries to write to each in turn until one +# succeeds. +# +# Caveats: +# - We do not verify the ancestor is actually Warp. In nested setups (tmux, +# screen, ssh into non-Warp), the OSC may end up in a terminal that ignores +# it. OSC 777 is widely ignored by terminals that don't implement it, so +# the worst case is a silently dropped notification — not corruption. +# - The walk is depth-limited to bound the number of `ps` invocations. + +find_candidate_ttys() { + local pid=$PPID + local depth=0 + while [[ -n $pid && $pid != 0 && $pid != 1 && $depth -lt 20 ]]; do + local tty + tty=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ') + if [[ -n $tty && $tty != "??" ]]; then + printf '/dev/%s\n' "$tty" + fi + pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') + depth=$((depth + 1)) + done +} diff --git a/plugins/warp/scripts/legacy/warp-notify.sh b/plugins/warp/scripts/legacy/warp-notify.sh index 6ca0588..7e8b662 100755 --- a/plugins/warp/scripts/legacy/warp-notify.sh +++ b/plugins/warp/scripts/legacy/warp-notify.sh @@ -2,9 +2,24 @@ # Warp notification utility using OSC escape sequences # Usage: warp-notify.sh <body> +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../find-controlling-tty.sh" + 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 +emit() { + printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > "$1" 2>/dev/null +} + +# Prefer /dev/tty. If unopenable (e.g. the hook is running inside a sandbox +# that detached the subprocess from its controlling terminal), fall back to +# trying each ancestor process's tty in turn until one accepts the write. +emit /dev/tty && exit 0 + +while read -r tty; do + emit "$tty" && exit 0 +done < <(find_candidate_ttys) + +exit 0 diff --git a/plugins/warp/scripts/warp-notify.sh b/plugins/warp/scripts/warp-notify.sh index 523f873..a3925ed 100755 --- a/plugins/warp/scripts/warp-notify.sh +++ b/plugins/warp/scripts/warp-notify.sh @@ -7,6 +7,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/should-use-structured.sh" +source "$SCRIPT_DIR/find-controlling-tty.sh" # Only emit notifications when we've confirmed the Warp build can render them. if ! should_use_structured; then @@ -17,5 +18,17 @@ 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 +emit() { + printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > "$1" 2>/dev/null +} + +# Prefer /dev/tty. If unopenable (e.g. the hook is running inside a sandbox +# that detached the subprocess from its controlling terminal), fall back to +# trying each ancestor process's tty in turn until one accepts the write. +emit /dev/tty && exit 0 + +while read -r tty; do + emit "$tty" && exit 0 +done < <(find_candidate_ttys) + +exit 0 diff --git a/plugins/warp/tests/test-find-controlling-tty.sh b/plugins/warp/tests/test-find-controlling-tty.sh new file mode 100755 index 0000000..7415a9e --- /dev/null +++ b/plugins/warp/tests/test-find-controlling-tty.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Tests for find-controlling-tty.sh +# +# The function walks up the process tree using `ps`. We override `ps` as a +# shell function so the tests don't depend on the actual process tree. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../scripts" && pwd)" +source "$SCRIPT_DIR/find-controlling-tty.sh" + +PASSED=0 +FAILED=0 + +assert_eq() { + local test_name="$1" + local expected="$2" + local actual="$3" + if [[ $expected == "$actual" ]]; then + echo " ✓ $test_name" + PASSED=$((PASSED + 1)) + else + echo " ✗ $test_name" + echo " expected: $expected" + echo " actual: $actual" + FAILED=$((FAILED + 1)) + fi +} + +echo "=== find-controlling-tty.sh ===" + +echo "" +echo "--- Emits the parent's tty when it has one ---" +ps() { + case "$*" in + "-o tty= -p $PPID") echo "ttys003" ;; + "-o ppid= -p $PPID") echo "1" ;; + *) command ps "$@" ;; + esac +} +result=$(find_candidate_ttys | tr '\n' ' ' | sed 's/ $//') +assert_eq "parent tty appears as /dev/<name>" "/dev/ttys003" "$result" +unset -f ps + +echo "" +echo "--- Skips ancestors with no controlling tty, keeps walking ---" +ps() { + case "$*" in + "-o tty= -p $PPID") echo "??" ;; + "-o tty= -p 77001") echo "??" ;; + "-o tty= -p 77002") echo "ttys004" ;; + "-o ppid= -p $PPID") echo "77001" ;; + "-o ppid= -p 77001") echo "77002" ;; + "-o ppid= -p 77002") echo "1" ;; + *) command ps "$@" ;; + esac +} +result=$(find_candidate_ttys | tr '\n' ' ' | sed 's/ $//') +assert_eq "?? entries are skipped, real tty emitted" "/dev/ttys004" "$result" +unset -f ps + +echo "" +echo "--- Emits multiple ancestors if more than one has a tty ---" +# A nested case (e.g. tmux): both inner and outer ancestors have ttys. +ps() { + case "$*" in + "-o tty= -p $PPID") echo "ttys001" ;; + "-o tty= -p 88001") echo "ttys002" ;; + "-o ppid= -p $PPID") echo "88001" ;; + "-o ppid= -p 88001") echo "1" ;; + *) command ps "$@" ;; + esac +} +result=$(find_candidate_ttys | tr '\n' ' ' | sed 's/ $//') +assert_eq "both ancestor ttys are emitted in order" "/dev/ttys001 /dev/ttys002" "$result" +unset -f ps + +echo "" +echo "--- Emits nothing when no ancestor has a tty ---" +ps() { + case "$*" in + "-o tty="*) echo "??" ;; + "-o ppid="*) echo "1" ;; + *) command ps "$@" ;; + esac +} +result=$(find_candidate_ttys) +assert_eq "no output when every ancestor reports ??" "" "$result" +unset -f ps + +echo "" +echo "--- Honors depth limit on unbounded process chains ---" +ps() { + case "$*" in + "-o tty="*) echo "??" ;; + "-o ppid="*) echo "9999" ;; + *) command ps "$@" ;; + esac +} +# Should terminate (not hang) even though every ppid points to a non-init pid. +start_secs=$SECONDS +find_candidate_ttys > /dev/null +elapsed=$((SECONDS - start_secs)) +if (( elapsed <= 2 )); then + echo " ✓ depth-limited walk terminates promptly" + PASSED=$((PASSED + 1)) +else + echo " ✗ depth-limited walk took too long (${elapsed}s)" + FAILED=$((FAILED + 1)) +fi +unset -f ps + +echo "" +echo "=== Results: $PASSED passed, $FAILED failed ===" + +if (( FAILED > 0 )); then + exit 1 +fi