fix(tmux): explicit transport-only dispatch and safe remote quoting (#1496)
This commit is contained in:
+22
-4
@@ -28,7 +28,7 @@ Example exchange:
|
||||
## The helper: `agent-send.sh`
|
||||
|
||||
Prepends the preamble automatically (auto-detecting your own `host:session`) and
|
||||
delivers reliably to local OR remote panes.
|
||||
dispatches transport to local OR remote panes; application acceptance remains unknown.
|
||||
|
||||
```bash
|
||||
# Local target (same host, default tmux server)
|
||||
@@ -47,7 +47,7 @@ echo "msg" | agent-send.sh -s <dst_session>
|
||||
|
||||
Key flags: `-L` named tmux socket · `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
|
||||
hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S`
|
||||
override source label · `-v` verbose · `-r N` Enter-flush attempts.
|
||||
override source label · `-v` transport metadata only · `-r N` compatibility-only, no retries.
|
||||
|
||||
For durable fleet use, prefer exact tmux targets such as `=coder0`. The helper
|
||||
normalizes exact session targets to pane-qualified targets internally so pane
|
||||
@@ -75,13 +75,31 @@ plain `Enter` keyname often does not register at all — `C-m` is needed.
|
||||
|
||||
`send-message.sh` solves this for a **local** pane: bracketed-paste the body
|
||||
(so multi-line content doesn't submit early), pause, then send `Enter` as its own
|
||||
keystroke and flush with a second, verifying against a draft heuristic.
|
||||
keystroke. It does not send automatic extra Enters. The legacy `-r` option
|
||||
is accepted for compatibility but no longer authorizes flushes.
|
||||
|
||||
Jason approved the **transport-only contract** after independent review demonstrated
|
||||
that screen layouts cannot prove application acceptance. Exit 0 means tmux accepted
|
||||
buffer load, one paste and one Enter command, not that the application submitted,
|
||||
queued or processed the message. Output explicitly says:
|
||||
`transport dispatched; application acceptance unknown`.
|
||||
|
||||
No capture-pane or editor/footer parser participates in transport success. Hidden
|
||||
retained drafts can coexist with successful transport; they are never called
|
||||
confirmed delivery. Failure exits remain nonzero (1 target resolution, 2 transport
|
||||
failed/partial/uncertain, 3 usage; wrapper 4 ambiguous socket). Failed paste is not
|
||||
retried with another mode. Never blindly replay a partial/uncertain operation.
|
||||
Verbose output is content-free transport metadata. Runtime-bound receipts are
|
||||
separate future work, not implemented by this tool.
|
||||
|
||||
`agent-send.sh` solves the **remote** case by _shipping `send-message.sh` over ssh_
|
||||
(`ssh host bash -s -- ... < send-message.sh`) and running it local to the target
|
||||
pane — so the reliable send-keys always happens on the pane's own host. The remote
|
||||
needs only `bash` + `tmux` + `base64`; **no mosaic install required there**. The
|
||||
message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||
message crosses the wire as base64 (`-b`). Every remote command argument is
|
||||
independently POSIX-shell quoted; retry syntax is validated before invoking SSH.
|
||||
The executing-shell regression suite checks quote-bearing target/socket arguments
|
||||
and rejects an invalid retry before any SSH call.
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
||||
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
||||
# DRAFT. tools/tmux/send-message.sh already solves that for a LOCAL pane via
|
||||
# bracketed-paste + Enter-flush + draft-detection. For REMOTE targets this
|
||||
# checked bracketed paste + one Enter (transport only). For REMOTE targets this
|
||||
# wrapper SHIPS send-message.sh over ssh (stdin) and runs it there, so the
|
||||
# reliable send-keys happens local to the target pane — sidestepping the
|
||||
# ssh->nested-tmux Enter/C-m swallow entirely. No mosaic install needed on
|
||||
@@ -57,8 +57,8 @@
|
||||
# byte-for-byte identical to the classic format. Consumers MUST
|
||||
# treat an absent class as 'actionable' (fail-safe: agent sees it).
|
||||
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
|
||||
# -r N Enter-flush attempts passed through (default 2)
|
||||
# -v verbose: print pane tail after delivery
|
||||
# -r N Legacy compatibility option; no automatic extra Enter
|
||||
# -v verbose: transport metadata only, no private pane contents
|
||||
# -h help
|
||||
#
|
||||
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
|
||||
@@ -67,7 +67,8 @@
|
||||
# group 3 = class (absent => actionable) group 4 = message body
|
||||
#
|
||||
# EXIT CODES (passed through from send-message.sh, except 4)
|
||||
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
||||
# 0 transport dispatched; application acceptance unknown · 1 target not found
|
||||
# 2 transport failed/partial/uncertain · 3 usage error
|
||||
# 4 agent-send refusal: local target session exists on multiple socket
|
||||
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||
set -uo pipefail
|
||||
@@ -106,6 +107,7 @@ while getopts "L:s:H:n:m:f:S:r:C:vh" o; do
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$RETRIES" =~ ^[0-9]+$ ]] || { echo 'ERROR: -r requires a nonnegative integer' >&2; exit 3; }
|
||||
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
|
||||
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
|
||||
|
||||
@@ -201,7 +203,10 @@ if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||
# would count as a false hit for target X (codex PR #1466).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" 2>/dev/null && hits="$hits$sname"$'\n'
|
||||
# Silence BOTH streams: has-session writes nothing to stdout, but a stub
|
||||
# (test fake) may — leaked probe stdout polluted this tool's stdout and
|
||||
# broke callers that read it (measured 2026-09-07, agent-send.test #9b).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" >/dev/null 2>&1 && hits="$hits$sname"$'\n'
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
@@ -224,6 +229,14 @@ if [ -z "$SSH_TARGET" ]; then
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
# SSH passes a command string through the remote login shell. Quote EACH
|
||||
# argument with POSIX single-quote escaping before that shell parses it.
|
||||
remote_args=(bash -s -- "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES")
|
||||
[ "$VERBOSE" = 0 ] || remote_args+=(-v)
|
||||
remote_command=""
|
||||
for arg in "${remote_args[@]}"; do
|
||||
escaped=${arg//\'/\'\\\'\'}
|
||||
remote_command+=" '$escaped'"
|
||||
done
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" "$remote_command" < "$SENDER"
|
||||
fi
|
||||
|
||||
@@ -28,7 +28,7 @@ TOOL="$HERE/agent-send.sh"
|
||||
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
||||
STUB=$(mktemp)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN" "$SCRATCH_TMPDIR"' EXIT
|
||||
cat >"$STUB" <<'STUB_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
@@ -62,9 +62,15 @@ no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
||||
# Run the tool with the stub injected; echoes captured payload on stdout.
|
||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
||||
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
|
||||
# depend on whether the caller running this suite sits inside tmux.
|
||||
# depend on whether the caller running this suite sits inside tmux — and
|
||||
# TMUX_TMPDIR is pinned to an empty scratch dir so socket discovery never
|
||||
# sees the HOST's sockets (measured 2026-09-07: with default+mosaic-fleet
|
||||
# live, discovery saw the fake answer 'mos' on both and B1-refused rc 4
|
||||
# before the stub ever ran; a one-socket host passed, so this only bites
|
||||
# multi-socket hosts).
|
||||
SCRATCH_TMPDIR=$(mktemp -d)
|
||||
run_auto() { # models a sender OUTSIDE tmux (no client context)
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX \
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX TMUX_TMPDIR="$SCRATCH_TMPDIR" \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
|
||||
+49
-171
@@ -1,181 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# send-message.sh — reliably deliver a message to a tmux pane running an
|
||||
# interactive REPL (e.g. a Claude Code / Codex agent).
|
||||
# send-message.sh — dispatch text through tmux; application acceptance unknown.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# Pasting multi-line text into an interactive agent REPL via `tmux send-keys`
|
||||
# is unreliable: the text lands in the input box but a single trailing Enter
|
||||
# in the same keystroke stream is frequently swallowed, so the message sits as
|
||||
# an UNSUBMITTED DRAFT ("Press up to edit queued messages") and the agent never
|
||||
# sees it. The mechanical fix is: paste as a bracketed paste (so embedded
|
||||
# newlines don't submit early), pause, then send Enter as its OWN keystroke,
|
||||
# pause, and send Enter again to flush. An extra Enter on an empty prompt is a
|
||||
# no-op in Claude Code, so the double-Enter is safe.
|
||||
#
|
||||
# USAGE
|
||||
# send-message.sh [-L socket_name] -t <target> -m "message"
|
||||
# send-message.sh [-L socket_name] -t <target> -f <file>
|
||||
# echo "message" | send-message.sh [-L socket_name] -t <target>
|
||||
# ssh host bash -s -- -L socket -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` (optional)
|
||||
# -t TARGET tmux target: session, or session:window.pane [required]
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -b BASE64 message as base64 (ssh-safe transport; decoded internally)
|
||||
# -r N Enter-flush attempts (default 2)
|
||||
# -v verbose: print a short tail of the pane after delivery
|
||||
# -h help
|
||||
#
|
||||
# EXIT CODES
|
||||
# 0 delivered (submitted) or queued (agent busy; will process when free)
|
||||
# 1 tmux target not found
|
||||
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
||||
# input box could not be located to confirm the message actually landed.
|
||||
# Locating the box is runtime-specific; see locate_input_box() below, and
|
||||
# add a shape there before pointing this tool at a new runtime.
|
||||
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
||||
# see the input box clear of the message (or the queued banner), we fail loud
|
||||
# so the sender learns immediately instead of a silent worker->lead stall.
|
||||
# 3 usage error
|
||||
# Usage: send-message.sh [-L socket] -t target {-m message|-f file|-b base64}
|
||||
# With no message option, reads stdin. Requires bash, tmux and base64.
|
||||
# -r N is compatibility-only: no automatic retries or extra Enter presses.
|
||||
# -v prints transport metadata only, never a captured private transcript.
|
||||
# Exit 0: tmux accepted buffer load, paste and one Enter command.
|
||||
# Exit 1: target resolution failed. Exit 2: transport failed/partial/uncertain.
|
||||
# Exit 3: invalid usage/input. No exit establishes application acknowledgement.
|
||||
set -uo pipefail
|
||||
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
|
||||
usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:t:m:f:b:r:vh" o; do
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; VERBOSE=0
|
||||
usage() { printf '%s\n' 'Usage: send-message.sh [-L socket] -t target [-m message|-f file|-b base64] [-r N] [-v]' 'Exit 0 = transport dispatched; application acceptance unknown. No automatic retries.'; exit "${1:-3}"; }
|
||||
while getopts 'L:t:m:f:b:r:vh' o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
L) SOCKET_NAME=$OPTARG ;; t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;;
|
||||
f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) [[ "$OPTARG" =~ ^[0-9]+$ ]] || usage 3 ;;
|
||||
v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET" ] || { echo "ERROR: -t TARGET is required" >&2; usage 3; }
|
||||
if [ -n "$B64" ]; then MSG=$(printf '%s' "$B64" | base64 -d) || { echo "ERROR: bad -b base64" >&2; exit 3; }
|
||||
elif [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
shift "$((OPTIND - 1))"
|
||||
[ "$#" -eq 0 ] && [ -n "$TARGET" ] || usage 3
|
||||
if [ -n "$B64" ]; then
|
||||
MSG=$(printf '%s' "$B64" | base64 -d) || { echo 'ERROR: invalid base64' >&2; exit 3; }
|
||||
elif [ -n "$FILE" ]; then
|
||||
MSG=$(cat -- "$FILE") || { echo 'ERROR: cannot read message file' >&2; exit 3; }
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then
|
||||
MSG=$(cat) || exit 3
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
[ -n "$MSG" ] || { echo 'ERROR: empty message' >&2; exit 3; }
|
||||
tmux_cmd=(tmux)
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
# tmux accepts `=session` for some commands, but pane-level commands such as
|
||||
# capture-pane require a pane-qualified target. Keep exact-session addressing
|
||||
# convenient while avoiding accidental prefix matches.
|
||||
[ -z "$SOCKET_NAME" ] || tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
EFFECTIVE_TARGET=$TARGET
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
|
||||
EFFECTIVE_TARGET="${TARGET}:0.0"
|
||||
fi
|
||||
|
||||
# Target must resolve to a live pane.
|
||||
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
|
||||
fi
|
||||
|
||||
QUEUED_RE='Press up to edit queued messages'
|
||||
# A distinctive tail of the message to spot an unsubmitted draft on the input line.
|
||||
snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g' | tail -c 32)
|
||||
|
||||
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
||||
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
||||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
||||
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
|
||||
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
|
||||
# simultaneous restart (briefs swapped between sessions).
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
||||
# -p = bracketed paste when the client supports it; fall back if not.
|
||||
"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|
||||
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
|
||||
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
|
||||
# (e.g. the target vanished since the liveness check), delete explicitly —
|
||||
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
|
||||
# would otherwise accumulate forever.
|
||||
sleep 0.5
|
||||
|
||||
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
||||
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
|
||||
# located at all. Found-but-empty is a real, distinct answer (an empty input box
|
||||
# is what a submitted message leaves behind), so the caller must branch on the
|
||||
# return code, never on whether the output is empty.
|
||||
#
|
||||
# Two REPL shapes are recognised:
|
||||
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
||||
# readline REPLs.
|
||||
# * a box drawn as two horizontal `─` rules with the input between them and NO
|
||||
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
|
||||
# what makes it safe: agent output can contain its own rules, but nothing is
|
||||
# drawn below the input box except the status line.
|
||||
#
|
||||
# Adding a runtime means adding its shape HERE. A shape that is missing does not
|
||||
# degrade gracefully: it turns every send to that runtime into a false
|
||||
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
|
||||
# arm of the same probe.
|
||||
locate_input_box() {
|
||||
local pane=$1 glyph_line rule_lines top bottom
|
||||
glyph_line=$(printf '%s\n' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||
if [ -n "$glyph_line" ]; then printf '%s\n' "$glyph_line"; return 0; fi
|
||||
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*─{4,}[[:space:]]*$' | cut -d: -f1 | tail -2)
|
||||
[ -n "$rule_lines" ] || return 1
|
||||
# Split the (at most two) captured line numbers with parameter expansion. Not
|
||||
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
|
||||
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
|
||||
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
|
||||
# With one rule captured both halves resolve to the same value and the
|
||||
# ordering test below rejects it, which is the answer we want anyway.
|
||||
top=${rule_lines%%$'\n'*}
|
||||
bottom=${rule_lines##*$'\n'}
|
||||
[ "$top" != "$bottom" ] || return 1
|
||||
[ "$bottom" -gt "$top" ] || return 1
|
||||
# An empty range (adjacent rules) prints nothing and still returns 0: found,
|
||||
# empty, which is the delivered shape.
|
||||
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
|
||||
return 0
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then EFFECTIVE_TARGET="${TARGET}:0.0"; fi
|
||||
# Pin one pane ID for all subsequent commands rather than resolving a moving
|
||||
# session/window target independently at every transport step.
|
||||
PANE=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" '#{pane_id}' 2>/dev/null) || {
|
||||
echo 'ERROR: tmux target resolution failed' >&2; exit 1;
|
||||
}
|
||||
|
||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
||||
# still a draft. Success requires positive evidence — the queued banner, OR the
|
||||
# REPL input box located AND clear of our message tail. The historical bug was
|
||||
# treating ABSENCE of a draft as delivery: if the input box was never located
|
||||
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
||||
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
||||
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
||||
status="unconfirmed"
|
||||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||||
sleep 1.2
|
||||
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
|
||||
|
||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||
status="queued"; break
|
||||
fi
|
||||
# If we cannot see the input box, we have NO evidence of submission state —
|
||||
# stay UNCONFIRMED and retry; never infer delivery.
|
||||
if ! inputbox=$(locate_input_box "$pane"); then
|
||||
status="unconfirmed"; continue
|
||||
fi
|
||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
||||
# (Submitted messages scroll up into history; a draft stays in the box.)
|
||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
|
||||
status="draft"; continue
|
||||
fi
|
||||
# Input box located AND clear of our tail => positively submitted. This is the
|
||||
# only path to success besides the queued banner.
|
||||
status="delivered"; break
|
||||
done
|
||||
|
||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||
|
||||
case "$status" in
|
||||
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
||||
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||||
esac
|
||||
[[ "$PANE" =~ ^%[0-9]+$ ]] || { echo 'ERROR: invalid resolved pane identity' >&2; exit 1; }
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
cleanup() { "${tmux_cmd[@]}" delete-buffer -b "$BUF" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
if ! printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -; then
|
||||
echo 'ERROR: buffer load failed; transport incomplete' >&2; exit 2
|
||||
fi
|
||||
# Do not retry a failed paste: failure may be partial. Bracketed paste is
|
||||
# requested once; changing paste mode after failure could duplicate effects.
|
||||
if ! "${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$PANE"; then
|
||||
echo 'ERROR: paste failed; transport uncertain; do not blindly resend' >&2; exit 2
|
||||
fi
|
||||
sleep 0.5
|
||||
if ! "${tmux_cmd[@]}" send-keys -t "$PANE" Enter; then
|
||||
echo 'ERROR: submission key failed; transport partial; do not blindly resend' >&2; exit 2
|
||||
fi
|
||||
[ "$VERBOSE" -eq 0 ] || printf 'transport pane=%s; paste_calls=1; submission_keys=1\n' "$PANE"
|
||||
printf '%s\n' 'transport dispatched; application acceptance unknown'
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the SSH command through a shell; no network or real tmux access."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
here = Path(__file__).resolve().parent
|
||||
with tempfile.TemporaryDirectory(prefix="tmux-remote-contract-") as temp:
|
||||
root = Path(temp)
|
||||
bindir = root / "bin"
|
||||
bindir.mkdir()
|
||||
(bindir / "ssh").write_text("#!/usr/bin/env python3\nimport os,subprocess,sys\nopen(os.environ['SSH_CALLS'],'a').write('ssh\\n')\nraise SystemExit(subprocess.call(['/bin/sh','-c',sys.argv[-1]]))\n")
|
||||
(bindir / "tmux").write_text("#!/usr/bin/env python3\nimport json,os,sys\na=sys.argv[1:]\nwith open(os.environ['CALLS'],'a') as f: f.write(json.dumps(a)+'\\n')\nif a and a[0]=='-L': a=a[2:]\nif a[0]=='display-message': print('%7')\nif a[0]=='load-buffer': open(os.environ['PAYLOAD'],'wb').write(sys.stdin.buffer.read())\n")
|
||||
for path in bindir.iterdir():
|
||||
path.chmod(0o755)
|
||||
env = dict(os.environ, PATH=str(bindir) + ':' + os.environ['PATH'], CALLS=str(root/'calls'), SSH_CALLS=str(root/'ssh-calls'), PAYLOAD=str(root/'payload'))
|
||||
env.pop('AGENT_SEND_SENDER', None)
|
||||
attack = "x'; printf 'UNAUTHORIZED_EXTRA_EFFECT\\n'; #"
|
||||
cases = [('target', ['-s', attack], 0), ('socket', ['-s', 'fixture', '-L', attack], 0), ('retry', ['-s', 'fixture', '-r', attack], 3)]
|
||||
for label, args, expected in cases:
|
||||
(root/'calls').write_text('')
|
||||
(root/'ssh-calls').write_text('')
|
||||
result = subprocess.run(['bash', str(here/'agent-send.sh'), '-S', 'review:src', '-n', 'fake', '-H', 'fake', '-m', 'safe\n你好'] + args, env=env, text=True, capture_output=True)
|
||||
assert result.returncode == expected, (label, result)
|
||||
assert 'UNAUTHORIZED_EXTRA_EFFECT' not in result.stdout, (label, result.stdout)
|
||||
calls = [json.loads(line) for line in (root/'calls').read_text().splitlines()]
|
||||
if expected == 3:
|
||||
assert not calls and not (root/'ssh-calls').read_text()
|
||||
else:
|
||||
assert result.stdout.strip() == 'transport dispatched; application acceptance unknown'
|
||||
command_calls = [a[2:] if a[0] == '-L' else a for a in calls]
|
||||
assert sum(a[0] == 'paste-buffer' for a in command_calls) == 1
|
||||
assert sum(a[0] == 'send-keys' for a in command_calls) == 1
|
||||
assert b'safe\n' in (root/'payload').read_bytes()
|
||||
if label == 'socket': assert all(a[:2] == ['-L', attack] for a in calls)
|
||||
if label == 'target': assert command_calls[0][3] == '=' + attack + ':0.0'
|
||||
print('PASS remote', label)
|
||||
@@ -73,7 +73,8 @@ require_tmux
|
||||
tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >"$TMPDIR/send-message-named.out"
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMPDIR/send-message-named.out" || fail 'missing transport-only result'
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
|
||||
@@ -82,7 +83,8 @@ if grep -qF "named socket hello" <<<"$default_pane"; then
|
||||
fail "send-message.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out
|
||||
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >"$TMPDIR/agent-send-named.out"
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMPDIR/agent-send-named.out" || fail 'wrapper changed transport-only result'
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deterministic transport-only contract tests; no live pane or model access.
|
||||
set -euo pipefail
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
mkdir "$TMP/bin"
|
||||
cat > "$TMP/bin/tmux" <<'FAKE'
|
||||
#!/usr/bin/env bash
|
||||
[ "${1:-}" != -L ] || shift 2
|
||||
printf '%s\n' "$1" >> "$CALLS"
|
||||
case "$1" in
|
||||
display-message) [ "$FAIL_OP" != target ] || exit 1; printf '%%7\n' ;;
|
||||
load-buffer) cat > "$PAYLOAD"; [ "$FAIL_OP" != load ] || exit 1 ;;
|
||||
paste-buffer) [ "$FAIL_OP" != paste ] || exit 1 ;;
|
||||
send-keys) [ "$FAIL_OP" != key ] || exit 1 ;;
|
||||
capture-pane) echo 'ERROR: transport must not inspect application display' >&2; exit 99 ;;
|
||||
esac
|
||||
FAKE
|
||||
chmod +x "$TMP/bin/tmux"
|
||||
body=$'你好 transport\nsecond line'
|
||||
for mode in none target load paste key; do
|
||||
: > "$TMP/calls"
|
||||
set +e
|
||||
PATH="$TMP/bin:$PATH" CALLS="$TMP/calls" PAYLOAD="$TMP/payload" FAIL_OP="$mode" \
|
||||
bash "$HERE/send-message.sh" -L isolated -t '=fixture' -r 999 -v -m "$body" > "$TMP/out" 2> "$TMP/err"
|
||||
rc=$?
|
||||
set -e
|
||||
expected=2; [ "$mode" != none ] || expected=0; [ "$mode" != target ] || expected=1
|
||||
[ "$rc" -eq "$expected" ]
|
||||
! grep -q capture-pane "$TMP/calls"
|
||||
keys=$(grep -c '^send-keys$' "$TMP/calls" || true)
|
||||
pastes=$(grep -c '^paste-buffer$' "$TMP/calls" || true)
|
||||
[ "$keys" -le 1 ] && [ "$pastes" -le 1 ]
|
||||
if [ "$mode" = none ]; then
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMP/out"
|
||||
[ "$keys" -eq 1 ] && [ "$pastes" -eq 1 ]
|
||||
[ "$(<"$TMP/payload")" = "$body" ]
|
||||
else
|
||||
! grep -q 'transport dispatched' "$TMP/out"
|
||||
fi
|
||||
echo "PASS transport $mode exit=$rc paste=$pastes keys=$keys"
|
||||
done
|
||||
@@ -1,131 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-send-message-verdict.sh — locks the fail-loud verdict logic of the patched
|
||||
# send-message.sh against three real tmux-pane fixtures on a throwaway socket:
|
||||
#
|
||||
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
||||
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
||||
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
|
||||
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
|
||||
# exit 0; post-patch it MUST fail loud (exit 2, stderr
|
||||
# "could not confirm submission").
|
||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||
# input line) => exit 2, stderr "unsubmitted draft".
|
||||
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
||||
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
|
||||
# the glyph probe could not see this box at all, so EVERY send
|
||||
# to such a pane reported "may be UNDELIVERED" while landing.
|
||||
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
||||
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
||||
# also reported unconfirmed, so the true state was invisible.
|
||||
set -uo pipefail
|
||||
|
||||
# Compatibility entry point. Screen-verdict semantics were rejected by
|
||||
# independent review and superseded by Jason's explicit transport-only ruling.
|
||||
# Historical fixtures and verdicts remain in frozen r2/r3 review exports.
|
||||
# Do not reinterpret their false confirmations as delivery successes.
|
||||
set -euo pipefail
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND="$HERE/send-message.sh"
|
||||
SOCKET="verdict-test-$RANDOM-$$"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
command -v tmux >/dev/null 2>&1 || { echo "tmux required" >&2; exit 1; }
|
||||
|
||||
# --- Fixture 1: a submitting REPL with a ❯ prompt box (interactive bash, glyph PS1).
|
||||
# readline strips bracketed-paste markers just like a real agent REPL; Enter
|
||||
# executes (text -> scrollback), leaving a fresh empty `❯ ` box.
|
||||
tmux -L "$SOCKET" new-session -d -s repl -c "$TMP" \
|
||||
'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
|
||||
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
||||
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
|
||||
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
|
||||
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
|
||||
else
|
||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixture 3: a ❯ box that never submits (sleep ignores stdin; TTY echo keeps the
|
||||
# pasted tail sitting on the ❯ line) => draft => exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s draft -c "$TMP" \
|
||||
'printf "❯ "; exec sleep infinity'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=draft" -r 1 -m "verdict fixture three stuck unsubmitted draft" 2>"$TMP/e3"); then
|
||||
no "draft: unsubmitted message must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e3"; then
|
||||
ok "draft: stuck ❯-line message => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: stuck ❯-line message => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e3")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
|
||||
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
|
||||
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
|
||||
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
|
||||
# MODE=keep leaves the text sitting in the box.
|
||||
cat > "$TMP/pibox.sh" <<'PIBOX'
|
||||
#!/usr/bin/env bash
|
||||
MODE=${1:-clear}
|
||||
RULE=$(printf '─%.0s' $(seq 1 60))
|
||||
buf=""
|
||||
draw() {
|
||||
printf '\033[H\033[2J'
|
||||
printf 'fixture output line\n\n'
|
||||
printf '%s\n' "$RULE"
|
||||
printf '%s\n' "$buf"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '~/fixture (main)\n'
|
||||
printf 'tok 0 model fixture\n'
|
||||
}
|
||||
draw
|
||||
while IFS= read -r line; do
|
||||
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
|
||||
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi
|
||||
draw
|
||||
done
|
||||
PIBOX
|
||||
chmod +x "$TMP/pibox.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
|
||||
fi
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
|
||||
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
|
||||
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
exec bash "$HERE/test-send-message-transport.sh"
|
||||
|
||||
Reference in New Issue
Block a user