Skip to content

Commit af2ae6b

Browse files
committed
Merge PR warpdotdev#44: fix OSC notifications when hooks have no controlling tty
2 parents b8ad3cc + cf1a685 commit af2ae6b

4 files changed

Lines changed: 181 additions & 4 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/bin/bash
2+
# Emit candidate controlling-tty paths by walking up the process tree.
3+
#
4+
# Background: when the hook runs inside a sandbox that detaches the subprocess
5+
# from its controlling terminal (e.g. Claude Code's macOS `sandbox-exec`
6+
# wrapper for Bash tool calls, when `sandbox.enabled` is set), the hook has
7+
# no controlling terminal — opening `/dev/tty` returns ENXIO. To still deliver
8+
# the OSC notification, we walk up the process tree and emit each ancestor's
9+
# tty device path; the caller tries to write to each in turn until one
10+
# succeeds.
11+
#
12+
# Caveats:
13+
# - We do not verify the ancestor is actually Warp. In nested setups (tmux,
14+
# screen, ssh into non-Warp), the OSC may end up in a terminal that ignores
15+
# it. OSC 777 is widely ignored by terminals that don't implement it, so
16+
# the worst case is a silently dropped notification — not corruption.
17+
# - The walk is depth-limited to bound the number of `ps` invocations.
18+
19+
find_candidate_ttys() {
20+
local pid=$PPID
21+
local depth=0
22+
while [[ -n $pid && $pid != 0 && $pid != 1 && $depth -lt 20 ]]; do
23+
local tty
24+
tty=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')
25+
if [[ -n $tty && $tty != "??" ]]; then
26+
printf '/dev/%s\n' "$tty"
27+
fi
28+
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
29+
depth=$((depth + 1))
30+
done
31+
}

plugins/warp/scripts/legacy/warp-notify.sh

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,24 @@
22
# Warp notification utility using OSC escape sequences
33
# Usage: warp-notify.sh <title> <body>
44

5+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+
source "$SCRIPT_DIR/../find-controlling-tty.sh"
7+
58
TITLE="${1:-Notification}"
69
BODY="${2:-}"
710

811
# OSC 777 format: \033]777;notify;<title>;<body>\007
9-
# Write directly to /dev/tty to ensure it reaches the terminal
10-
printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > /dev/tty 2>/dev/null || true
12+
emit() {
13+
printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > "$1" 2>/dev/null
14+
}
15+
16+
# Prefer /dev/tty. If unopenable (e.g. the hook is running inside a sandbox
17+
# that detached the subprocess from its controlling terminal), fall back to
18+
# trying each ancestor process's tty in turn until one accepts the write.
19+
emit /dev/tty && exit 0
20+
21+
while read -r tty; do
22+
emit "$tty" && exit 0
23+
done < <(find_candidate_ttys)
24+
25+
exit 0

plugins/warp/scripts/warp-notify.sh

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
99
source "$SCRIPT_DIR/should-use-structured.sh"
10+
source "$SCRIPT_DIR/find-controlling-tty.sh"
1011

1112
# Only emit notifications when we've confirmed the Warp build can render them.
1213
if ! should_use_structured; then
@@ -17,5 +18,17 @@ TITLE="${1:-Notification}"
1718
BODY="${2:-}"
1819

1920
# OSC 777 format: \033]777;notify;<title>;<body>\007
20-
# Write directly to /dev/tty to ensure it reaches the terminal
21-
printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > /dev/tty 2>/dev/null || true
21+
emit() {
22+
printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > "$1" 2>/dev/null
23+
}
24+
25+
# Prefer /dev/tty. If unopenable (e.g. the hook is running inside a sandbox
26+
# that detached the subprocess from its controlling terminal), fall back to
27+
# trying each ancestor process's tty in turn until one accepts the write.
28+
emit /dev/tty && exit 0
29+
30+
while read -r tty; do
31+
emit "$tty" && exit 0
32+
done < <(find_candidate_ttys)
33+
34+
exit 0
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/bin/bash
2+
# Tests for find-controlling-tty.sh
3+
#
4+
# The function walks up the process tree using `ps`. We override `ps` as a
5+
# shell function so the tests don't depend on the actual process tree.
6+
7+
set -uo pipefail
8+
9+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../scripts" && pwd)"
10+
source "$SCRIPT_DIR/find-controlling-tty.sh"
11+
12+
PASSED=0
13+
FAILED=0
14+
15+
assert_eq() {
16+
local test_name="$1"
17+
local expected="$2"
18+
local actual="$3"
19+
if [[ $expected == "$actual" ]]; then
20+
echo "$test_name"
21+
PASSED=$((PASSED + 1))
22+
else
23+
echo "$test_name"
24+
echo " expected: $expected"
25+
echo " actual: $actual"
26+
FAILED=$((FAILED + 1))
27+
fi
28+
}
29+
30+
echo "=== find-controlling-tty.sh ==="
31+
32+
echo ""
33+
echo "--- Emits the parent's tty when it has one ---"
34+
ps() {
35+
case "$*" in
36+
"-o tty= -p $PPID") echo "ttys003" ;;
37+
"-o ppid= -p $PPID") echo "1" ;;
38+
*) command ps "$@" ;;
39+
esac
40+
}
41+
result=$(find_candidate_ttys | tr '\n' ' ' | sed 's/ $//')
42+
assert_eq "parent tty appears as /dev/<name>" "/dev/ttys003" "$result"
43+
unset -f ps
44+
45+
echo ""
46+
echo "--- Skips ancestors with no controlling tty, keeps walking ---"
47+
ps() {
48+
case "$*" in
49+
"-o tty= -p $PPID") echo "??" ;;
50+
"-o tty= -p 77001") echo "??" ;;
51+
"-o tty= -p 77002") echo "ttys004" ;;
52+
"-o ppid= -p $PPID") echo "77001" ;;
53+
"-o ppid= -p 77001") echo "77002" ;;
54+
"-o ppid= -p 77002") echo "1" ;;
55+
*) command ps "$@" ;;
56+
esac
57+
}
58+
result=$(find_candidate_ttys | tr '\n' ' ' | sed 's/ $//')
59+
assert_eq "?? entries are skipped, real tty emitted" "/dev/ttys004" "$result"
60+
unset -f ps
61+
62+
echo ""
63+
echo "--- Emits multiple ancestors if more than one has a tty ---"
64+
# A nested case (e.g. tmux): both inner and outer ancestors have ttys.
65+
ps() {
66+
case "$*" in
67+
"-o tty= -p $PPID") echo "ttys001" ;;
68+
"-o tty= -p 88001") echo "ttys002" ;;
69+
"-o ppid= -p $PPID") echo "88001" ;;
70+
"-o ppid= -p 88001") echo "1" ;;
71+
*) command ps "$@" ;;
72+
esac
73+
}
74+
result=$(find_candidate_ttys | tr '\n' ' ' | sed 's/ $//')
75+
assert_eq "both ancestor ttys are emitted in order" "/dev/ttys001 /dev/ttys002" "$result"
76+
unset -f ps
77+
78+
echo ""
79+
echo "--- Emits nothing when no ancestor has a tty ---"
80+
ps() {
81+
case "$*" in
82+
"-o tty="*) echo "??" ;;
83+
"-o ppid="*) echo "1" ;;
84+
*) command ps "$@" ;;
85+
esac
86+
}
87+
result=$(find_candidate_ttys)
88+
assert_eq "no output when every ancestor reports ??" "" "$result"
89+
unset -f ps
90+
91+
echo ""
92+
echo "--- Honors depth limit on unbounded process chains ---"
93+
ps() {
94+
case "$*" in
95+
"-o tty="*) echo "??" ;;
96+
"-o ppid="*) echo "9999" ;;
97+
*) command ps "$@" ;;
98+
esac
99+
}
100+
# Should terminate (not hang) even though every ppid points to a non-init pid.
101+
start_secs=$SECONDS
102+
find_candidate_ttys > /dev/null
103+
elapsed=$((SECONDS - start_secs))
104+
if (( elapsed <= 2 )); then
105+
echo " ✓ depth-limited walk terminates promptly"
106+
PASSED=$((PASSED + 1))
107+
else
108+
echo " ✗ depth-limited walk took too long (${elapsed}s)"
109+
FAILED=$((FAILED + 1))
110+
fi
111+
unset -f ps
112+
113+
echo ""
114+
echo "=== Results: $PASSED passed, $FAILED failed ==="
115+
116+
if (( FAILED > 0 )); then
117+
exit 1
118+
fi

0 commit comments

Comments
 (0)