test/e2e_10_agent.sh
Ref: Size: 26.5 KiB History
# shellcheck shell=sh
# e2e_10_agent.sh — sourced by test/e2e.sh after e2e_lib.sh. Scenarios run in
# the order they stand in; see the lib's header for what this file may
# assume and what it must register.
# Agent forwarding: one socket path, two daemons in turn. The positive leg
# ends by typing `exit`, which ends that daemon's ONLY session — a wall of
# one tile ends with it, which is what takes the client and the daemon
# down together. The legs whose daemon also holds its own default session
# leave by chord instead: there a tile is left standing, and the wall stays
# up. The refusal leg below starts its own daemon on the freed path rather
# than inheriting one that is already gone. Both are $MUX; neither
# may be auto-started (see the $PATH note at the top of this file).
#
# The agent lives beside the sockets rather than under $OUT: a unix path is
# capped at 107 bytes by sun_path, and this one is dialled from inside a
# session shell, so the room it has is the room every other socket in this
# file was given.
SOCK48="${TMPDIR:-/tmp}/muxd-e2e-agentfwd-$$.sock"
defer_sock "$SOCK48"
AGENT48="${TMPDIR:-/tmp}/mux-e2e-agent-$$.sock"
defer_rm "$AGENT48"
AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$"
defer_rm "$AGENT48KEY" "$AGENT48KEY.pub"
# The flip leg gets a daemon of its own, and not for isolation's sake: the
# one above is stopped by its own scenario's assert_stopped, so there is no
# daemon left to reuse. Two agents, two keys, two sockets — one agent behind
# both clients would print the same fingerprint whichever slot answered, and
# the leg would pass having proved nothing. Beside the sockets for the
# sun_path reason above; these are dialled from inside a session too.
SOCK49="${TMPDIR:-/tmp}/muxd-e2e-agentflip-$$.sock"
defer_sock "$SOCK49"
AGENT49A="${TMPDIR:-/tmp}/mux-e2e-agent2a-$$.sock"
defer_rm "$AGENT49A"
AGENT49B="${TMPDIR:-/tmp}/mux-e2e-agent2b-$$.sock"
defer_rm "$AGENT49B"
AGENT49AKEY="${TMPDIR:-/tmp}/mux-e2e-agent2akey-$$"
defer_rm "$AGENT49AKEY" "$AGENT49AKEY.pub"
AGENT49BKEY="${TMPDIR:-/tmp}/mux-e2e-agent2bkey-$$"
defer_rm "$AGENT49BKEY" "$AGENT49BKEY.pub"
# --- agent forwarding: a real key in the client's agent answers in the session
#
# Everything under this heading is a real program doing its real job: a real
# ssh-agent holding a real ed25519 key, a real `ssh-add -l` typed into a real
# session shell on a real pty. Nothing here stands in for anything, and that
# is the point — the daemon and the client both carry the agent protocol
# BLIND (server.zig's serviceAgentChan copies bytes it never parses), so a
# stand-in agent would be this suite grading the pump against a second guess
# at a format neither end reads. openssh either recognises what came back or
# it does not, and that answer is the only one worth having.
#
# The needle is a SHA256 fingerprint, and it is echo-proof by the M12 rule
# (tp2, argued at the OSC 52 leg above): the shell echoes what is typed, so
# a needle a keystroke could spell would pass on a client that forwarded
# nothing at all. The typed line is `ssh-add -l`, which cannot spell a
# fingerprint. What puts those 50 bytes on the terminal is the agent on this
# machine answering a request that left the session, crossed the daemon,
# reached the client, and came back.
start_daemon "$SOCK48" "$OUT.agt.d" "agent daemon never bound" --shell /bin/sh
D42PID=$DPID
# The agent is started AFTER the daemon and its path is never exported, so
# the daemon cannot have inherited it: the only SSH_AUTH_SOCK a session on
# this daemon can hold is one the daemon bound itself. That ordering is what
# makes the fingerprint below evidence of a forward rather than of a leak.
ssh-agent -a "$AGENT48" > "$OUT.agt.env" 2>&1
AGENT48PID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.agt.env")
defer_kill "$AGENT48PID"
[ -n "$AGENT48PID" ] || {
echo "e2e FAIL: agent: ssh-agent printed no pid for the trap to hold it by:"
cat "$OUT.agt.env"; exit 1; }
# -C, so `ssh-add -l` prints a short comment instead of this key's whole
# path: the answer has to fit one row of the terminal below, and a line that
# wrapped could split the fingerprint across two rows the capture never
# joins back up.
ssh-keygen -q -t ed25519 -N '' -C mux-e2e-agent -f "$AGENT48KEY"
SSH_AUTH_SOCK="$AGENT48" ssh-add "$AGENT48KEY" > /dev/null 2>&1
FP48=$(ssh-keygen -lf "$AGENT48KEY" | awk '{print $2}')
# An empty or malformed needle would reach ptyclient as a usage error rather
# than as a failed expectation, and the suite would report the wrong thing.
case "$FP48" in
SHA256:?*) ;;
*) echo "e2e FAIL: agent: ssh-keygen -lf gave no fingerprint to look for: [$FP48]"
exit 1;;
esac
# Unquoted heredoc, like the OSC 52 leg's and for the same reason: $FP48 has
# to interpolate. Nothing else in the script needs expanding — `\n` and
# `\x1b` are not escapes the shell touches.
#
# No BACKTICKS below this line, comments included, and that is a rule and
# not a preference: an unquoted heredoc runs `word` as a command
# substitution wherever it appears, and the comments here used to quote
# `x`, `d`, `expect` and `settle` the way the rest of this repo quotes a
# key or a verb. On Linux all four are "command not found" and the run
# carries on none the wiser. On macOS /usr/bin/expect exists, so the shell
# started it, it read the rest of the heredoc as its own script, and the
# group hung until its budget ran out (2026-09-04).
set +e
hostroom agt48
XDG_STATE_HOME="$HOSTROOM" SSH_AUTH_SOCK="$AGENT48" timeout 40 "$PTYCLIENT" --cols 100 --rows 30 \
--out "$OUT.agt" --err "$OUT.agt.err" \
-- "$MUX" -A --sock "$SOCK48" > "$OUT.agt.log" 2>&1 <<EOF
expect \x1b[?1049h 15000
settle 400 15000
send ssh-add -l\n
expect $FP48 15000
settle 400 15000
send exit\n
# On a TERMINAL the last tile's exit leaves an EMPTY WALL — the x key ends
# a session, never mux — and the picker opens over it. Esc closes the popup
# and the d key is what leaves. A piped mux still exits with the shell's
# code.
#
# An expect verb here rather than a settle: the popup is the CLAIM, so
# waiting for its legend is what makes this leg fail if the exit ever ends
# mux again instead. The empty-wall scenario asserts a picker on a wall
# that never had a tile; only this one sees the TRANSITION.
expect c new session 15000
send \x1b
settle 300 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
# Only the fixture's log, not the capture beside it: ptyclient prints the
# verb that failed and the last 200 bytes it saw, which is the answer. The
# capture is a screenful of escapes that would bury it.
[ "$RC" -eq 0 ] || {
echo "e2e FAIL: agent: the client's key never answered ssh-add -l in the"
echo " session (wanted $FP48):"
cat "$OUT.agt.log"; exit 1; }
# The transition, on the GRID: the shell exited, mux stayed up, and what it
# put on the screen was the empty wall's one line under the host picker.
# The stream would show the box either way — only the render says the user
# was still looking at it when the Esc arrived.
"$RENDER" --cols 100 --rows 30 < "$OUT.agt" > "$OUT.agtgrid" || {
echo "e2e FAIL: agent: render oracle failed"; cat "$OUT.agtgrid"; exit 1; }
grep -q "the wall is empty" "$OUT.agtgrid" || {
echo "e2e FAIL: agent: the sole session's exit did not leave an empty wall:"
cat "$OUT.agtgrid"; exit 1; }
ok "agent forwarding: ssh-add -l in the session lists the client's key"
# --- ...and `-A` with nothing behind it is refused before the attach ------
#
# The flag is a promise the client cannot keep with no agent running, and it
# used to be kept silently: the offer is a declaration rather than a
# capability, so every dial the session made was refused without a word and
# the user met it three layers down as `permission denied (publickey)` from
# a git remote. Refused at usage-error altitude now, exit 2, before the
# transport is touched.
#
# Both spellings of "no agent", because reading the variable would only
# catch the first: unset, and set to a path nothing answers on. The unit
# test covers the subtler third — an agent killed leaving its socket file
# behind, which stats fine and refuses the connect — since arranging a
# listener that dies mid-leg buys nothing here.
set +e
env -u SSH_AUTH_SOCK "$MUX" -A --sock "$SOCK48" > "$OUT.anoag" 2>&1
RC=$?
set -e
[ "$RC" -eq 2 ] || {
echo "e2e FAIL: agent: -A with no SSH_AUTH_SOCK exited $RC, want 2:"
cat "$OUT.anoag"; exit 1; }
grep -q "is not set" "$OUT.anoag" || {
echo "e2e FAIL: agent: -A with no SSH_AUTH_SOCK refused for another reason:"
cat "$OUT.anoag"; exit 1; }
set +e
SSH_AUTH_SOCK="$OUT.dead-agent.sock" "$MUX" -A --sock "$SOCK48" > "$OUT.anoag2" 2>&1
RC=$?
set -e
[ "$RC" -eq 2 ] || {
echo "e2e FAIL: agent: -A at a dead agent path exited $RC, want 2:"
cat "$OUT.anoag2"; exit 1; }
grep -q "no ssh-agent answering at" "$OUT.anoag2" || {
echo "e2e FAIL: agent: -A at a dead agent path refused for another reason:"
cat "$OUT.anoag2"; exit 1; }
# The control: the SAME command with the SAME live agent the leg above used
# gets PAST the preflight, so the two refusals are about the agent and not
# about the flag being rejected outright. Bounded and its exit ignored —
# past the preflight it goes on to attach for real, which without a
# terminal is neither a pass nor a failure, only slow.
set +e
SSH_AUTH_SOCK="$AGENT48" timeout 5 "$MUX" -A --sock "$SOCK48" > "$OUT.anoag3" 2>&1
set -e
# `if`, not `grep && {...}`: under `set -e` an AND-list ending in a failed
# grep takes the whole suite down, and here a failed grep is the PASS.
if grep -q "no ssh-agent" "$OUT.anoag3"; then
echo "e2e FAIL: agent: a REACHABLE agent was still refused by the preflight:"
cat "$OUT.anoag3"; exit 1
fi
ok "agent forwarding: -A with no agent is a usage error, not a silent no-op"
# --- ...and a session nobody offered an agent to refuses, fast -------------
#
# A fresh daemon on the path, and both halves of that are load-bearing. The
# leg above ended its last session but not its DAEMON — a daemon lives until
# `mux d stop` — so the old one is stopped by name here rather than waited
# out. And the new one is started before anything dials, because a client
# dialling an unserved $SOCK48 would AUTO-START one off $PATH instead — a
# daemon that need not be the one under test, and against a release with no
# agent code in it every assertion below passes having witnessed nothing.
#
# The sibling case — a session beside one that HAS an answerer — is the unit
# suite's; what this leg witnesses is the plainer shape, a live daemon
# holding a single session nobody has offered an agent to.
#
# SSH_AUTH_SOCK is still set inside that session — every session is born
# with one, whether or not anyone has offered — and it points at the daemon,
# which accepts the dial and closes it. That is the designed answer, not a
# gap: ssh reads a closed agent socket as "agent refused operation" and
# falls straight through to its other methods, where a connection accepted
# and left silent would make it wait out a timeout on every dial.
assert_stopped "$SOCK48" "$D42PID" "agent forwarding: the positive leg's daemon" "$OUT.agtstop0"
start_daemon "$SOCK48" "$OUT.agtn.d" "agent-refusal daemon never bound" --shell /bin/sh
D42PID=$DPID
AR0=$(now_ms)
set +e
hostroom agtn
# The neighbour, written down: the wall is the layout, so the daemon's own
# `0` is a pane only if a leaf names it. Without it the `exit` below leaves
# an EMPTY wall and the picker over it, and the `\x1cd` that follows would
# be eaten by the popup.
seed_layout "$HOSTROOM" stacked "--sock $SOCK48#0"
XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.agtn" --err "$OUT.agtn.err" \
-- "$MUX" --sock "$SOCK48" --session noagent > "$OUT.agtn.log" 2>&1 <<'EOF'
expect \x1b[?1049h 15000
settle 400 15000
send echo agtsock=${SSH_AUTH_SOCK:+present}\n
expect agtsock=present 15000
send ssh-add -l; echo agtrc=$?\n
expect agtrc= 15000
settle 400 15000
send exit\n
settle 600 15000
# The session that ended is not the wall: this daemon holds its own default
# session too, so the tile vanishes and the focus falls to the neighbour.
# Leaving is the chord.
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
AR1=$(now_ms)
AMS=$(( AR1 - AR0 ))
rc0 "agent-refusal: the leg exited $RC:" "$OUT.agtn.log"
# Quoted heredoc, unlike the leg above: `$?` has to reach the SESSION's
# shell as two characters, not be spent by this one.
#
# `expect agtsock=present` is the leg's non-vacuity pin, and unlike the
# needle below it IS an assertion: the typed line spells the parameter
# expansion, so only a shell that ran it can put the word `present` on its
# own line. Everything else here would pass just as well against a daemon
# that had never heard of agents — no SSH_AUTH_SOCK at all also means no
# key, no signature and a nonzero status. This verb is what separates
# "accepted the dial and closed it" from "nothing was ever offered".
#
# `expect agtrc=` is a wait and not an assertion — the shell echoes the line
# it was typed, so that needle matches before ssh-add has run. The settle
# after it is what waits for the answer; these two greps are the assertion,
# and both are echo-proof, because the typed line spells `agtrc=$?` and
# neither `agtrc=0` nor a digit can come from echoing it.
grep -q "agtrc=0" "$OUT.agtn" && {
echo "e2e FAIL: agent-refusal: ssh-add succeeded in a session nobody offered"
echo " an agent to — something answered for a key that is not there:"
cat -v "$OUT.agtn"; exit 1; }
grep -qE "agtrc=[1-9]" "$OUT.agtn" || {
echo "e2e FAIL: agent-refusal: ssh-add never reported a status at all — the"
echo " session did not run it, so its failure proves nothing:"
cat -v "$OUT.agtn"; exit 1; }
# Ceiling only, and the floor's absence is the claim: a refusal is a close on
# an already-accepted connection and should cost about nothing, so there is
# no lower bound worth defending. What the ceiling owns is the band this
# leg's own deadlines cannot see. A refusal that HUNG is caught by the
# waitexit above and reported as an exit code; a refusal that came back
# after ten seconds of retrying is not — `expect` matched the echo long
# before, and the settle would sit through the whole retry and still pass.
# This bound is what makes that band a failure.
#
# 10000 for the reason the key-mismatch leg above picked the same number: it
# separates "refused" from "sat in a loop" and is not asked to do anything
# finer. The whole leg — attach, two 400ms settles, the agtsock round trip,
# a shell's startup and the refusal itself — measured 816-837ms across the
# grading runs against the daemon started above, so a tighter ceiling would
# catch nothing this one misses while making a check that is nobody's
# bottleneck the suite's most load-sensitive one.
[ "$AMS" -lt 10000 ] || {
echo "e2e FAIL: agent-refusal: the leg took ${AMS}ms — a dial with nobody to"
echo " answer it must be refused, not retried"
exit 1; }
# --- ...and a nested `-A` inside such a session is refused, not passed -----
#
# The preflight's whole job is to refuse a client that cannot answer a
# challenge. Inside a session `SSH_AUTH_SOCK` names the daemon, which
# accepts every dial and only afterwards looks for an offerer to route it
# to — so the connect the preflight used to do succeeded there, and the
# nested client attached as an offerer that can answer nothing. Worse, it
# out-ranks a working `-A` client, because the answerer is the latest-active
# OFFERER and offering is a declaration.
#
# `--session noagent` is the session this shell is already in, deliberately:
# the preflight runs before any transport work, so it is what must answer
# here, and the self-attach refusal standing behind it means a regression
# fails this leg instead of spawning a nested attach into the pty. Which of
# the two spoke is what the message grep decides; the exit code alone
# cannot, since both refusals use 2.
#
# Unquoted heredoc, unlike the leg above: $MUX and $SOCK48 have to be
# expanded by THIS shell, so `$?` is escaped to reach the session's.
set +e
hostroom agtnest
# The neighbour, for the leg above's reason.
seed_layout "$HOSTROOM" stacked "--sock $SOCK48#0"
XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.agtnest" --err "$OUT.agtnest.err" \
-- "$MUX" --sock "$SOCK48" --session noagent > "$OUT.agtnest.log" 2>&1 <<EOF
expect \x1b[?1049h 15000
settle 400 15000
send $MUX -A --sock $SOCK48 --session noagent; echo nest=\$?\n
settle 1500 15000
send exit\n
settle 600 15000
# The session that ended is not the wall: this daemon holds its own default
# session too, so the tile vanishes and the focus falls to the neighbour.
# Leaving is the chord.
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
rc0 "agent-nested: the leg exited $RC:" "$OUT.agtnest.log"
# Both needles are echo-proof: the typed line spells `nest=$?` and says
# nothing about ssh-agents, so neither `nest=2` nor the message below can
# come from the shell echoing what it was sent.
grep -q "nest=2" "$OUT.agtnest" || {
echo "e2e FAIL: agent-nested: a nested \`mux -A\` did not exit 2 — the"
echo " preflight let through a client with no agent behind it:"
cat -v "$OUT.agtnest"; exit 1; }
grep -q "no ssh-agent answering at" "$OUT.agtnest" || {
echo "e2e FAIL: agent-nested: the nested \`mux -A\` was refused, but not by"
echo " the agent preflight — something else answered first:"
cat -v "$OUT.agtnest"; exit 1; }
ok "agent forwarding: a nested -A with nobody offering is refused"
assert_stopped "$SOCK48" "$D42PID" "agent forwarding" "$OUT.agtstop"
D42PID=""
# The agent has no more work; ended here rather than in the trap so a green
# run does not leave a key-holding process alive for the length of the
# teardown. The trap still names it, for every run that does not reach here.
softkill "$AGENT48PID" || true
AGENT48PID=""
# The measurement is printed on a PASSING run too, not just quoted in the
# failure above: the band this leg's ceiling was chosen against lives in
# docs/decisions.md, and a number nobody can see on a green run is a number
# that quietly stops being true.
ok "agent forwarding: no offerer means a fast refusal, not a hang (${AMS}ms)"
# --- ...and the agent that answers is whoever typed last -------------------
#
# Two clients on ONE session, each offering its OWN ssh-agent holding its
# OWN key. `ssh-add -l` typed at one keyboard and then at the other has to
# come back with two DIFFERENT fingerprints — that is the spec's "latest
# wins, applied to keys", and it is a claim only two agents can make.
start_daemon "$SOCK49" "$OUT.flip.d" "agent-flip daemon never bound" --shell /bin/sh
D43PID=$DPID
# Both agents after the daemon, for the ordering reason the leg above gives:
# a path the daemon cannot have inherited is what makes a fingerprint
# evidence of a forward rather than of a leak.
ssh-agent -a "$AGENT49A" > "$OUT.flipa.env" 2>&1
AGENT49APID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.flipa.env")
defer_kill "$AGENT49APID"
ssh-agent -a "$AGENT49B" > "$OUT.flipb.env" 2>&1
AGENT49BPID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.flipb.env")
defer_kill "$AGENT49BPID"
[ -n "$AGENT49APID" ] && [ -n "$AGENT49BPID" ] || {
echo "e2e FAIL: agent-flip: an ssh-agent printed no pid for the trap to hold"
cat "$OUT.flipa.env" "$OUT.flipb.env"; exit 1; }
# -C for the wrap reason the leg above gives, and two different comments so
# a capture holding the wrong answer says out loud whose it is.
ssh-keygen -q -t ed25519 -N '' -C mux-e2e-flip-a -f "$AGENT49AKEY"
ssh-keygen -q -t ed25519 -N '' -C mux-e2e-flip-b -f "$AGENT49BKEY"
SSH_AUTH_SOCK="$AGENT49A" ssh-add "$AGENT49AKEY" > /dev/null 2>&1
SSH_AUTH_SOCK="$AGENT49B" ssh-add "$AGENT49BKEY" > /dev/null 2>&1
FPA=$(ssh-keygen -lf "$AGENT49AKEY" | awk '{print $2}')
FPB=$(ssh-keygen -lf "$AGENT49BKEY" | awk '{print $2}')
for _fp in "$FPA" "$FPB"; do
case "$_fp" in
SHA256:?*) ;;
*) echo "e2e FAIL: agent-flip: ssh-keygen -lf gave no fingerprint to look for: [$_fp]"
exit 1;;
esac
done
# Never expected, and asserted anyway, because it is the one failure this
# leg cannot see: two keys that hashed alike would satisfy both expects
# below without anything having flipped.
[ "$FPA" != "$FPB" ] || {
echo "e2e FAIL: agent-flip: both agents hold the same fingerprint [$FPA] —"
echo " no expect below could tell one answerer from the other"
exit 1; }
# The sequencing, and why it needs no sleeps.
#
# `agentAnswerer` (server.zig) ranks the session's offerers by
# ClientSlot.activity and reads that ranking ONCE, when ssh-add's dial is
# accepted. Activity is bumped by three verbs only — attach, input, resize
# (bumpActivity) — and the input bump happens as the frame is handled,
# strictly before those bytes reach the pty. So a client's own query is
# always the last bump before its own dial, PROVIDED nobody else attaches or
# types in between. That proviso is the entire choreography:
#
# the second attach is a bump too, so A cannot simply query first and hope
# B has not arrived yet. Instead B announces itself by TYPING and A waits
# for that line to reach its own screen. A's query is then provably after
# B's attach AND after B's only keystroke, because A did not type until
# B's bytes had crossed the daemon, the pty and back;
#
# the baton back is the same trick mirrored: A types a marker after its
# answer, B is watching the same grid and types only once it lands, so B's
# bump is provably after A's and B's dial is routed to B.
#
# Which also makes the leg independent of which client attaches first — the
# order is fixed by typed markers, not by process start.
#
# Both markers are matched on the shell's ECHO, and that is sound here in a
# way it would not be for an assertion: the M12 echo-proof rule guards
# needles that claim something crossed the WIRE, and a marker claims only
# that a keystroke was handled — which is precisely what an echo witnesses,
# and witnesses earlier than any command output could.
#
# The fingerprints are not markers and are echo-proof by that same rule:
# `ssh-add -l` cannot spell one.
#
# Unquoted heredocs, like the positive leg's: $FPA and $FPB have to
# interpolate, and nothing else in either script needs expanding.
set +e
hostroom agt49a
XDG_STATE_HOME="$HOSTROOM" SSH_AUTH_SOCK="$AGENT49A" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
--out "$OUT.flipa" --err "$OUT.flipa.err" \
-- "$MUX" -A --sock "$SOCK49" > "$OUT.flipa.log" 2>&1 <<EOF &
expect \x1b[?1049h 15000
expect b-ready 25000
send ssh-add -l\n
expect $FPA 20000
send echo flip-now\n
expect $FPB 25000
send exit\n
# Empty wall, then the picker: see the first agent leg, and an expect verb
# on the popup's own legend, never a settle. The picker opens on the host
# POLL (up to a second after the last session dies) while a settle fires a
# fixed time after the last output byte; a picker that opened between the
# settle and the Esc would eat the d that follows (every byte is the
# popup's while it is up) and this leg would fail at waitexit on a change
# nobody made.
#
# No backticks below the heredoc opener, comments included, for the reason
# written above the first agent leg's script: in an unquoted heredoc the
# shell runs a backquoted word as a command substitution wherever it
# stands, and this comment used to quote the expect verb that way. There is
# an expect program on macOS, so the shell started it, it read the rest of
# the heredoc as its own script, and the gate hung for its whole budget.
expect c new session 20000
send \x1b
settle 300 20000
send \x1cd
waitexit 20000
EOF
FLIPAPID=$!
defer_kill "$FLIPAPID"
# B settles before its first keystroke and A does not need to: a shell still
# setting up its terminal can flush what was typed at it, and B's marker is
# the only line typed before any shell has spoken. Every later send waits on
# a marker, which is a barrier a settle could only approximate.
hostroom agt49b
XDG_STATE_HOME="$HOSTROOM" SSH_AUTH_SOCK="$AGENT49B" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
--out "$OUT.flipb" --err "$OUT.flipb.err" \
-- "$MUX" -A --sock "$SOCK49" > "$OUT.flipb.log" 2>&1 <<EOF
expect \x1b[?1049h 15000
settle 400 15000
send echo b-ready\n
expect flip-now 30000
send ssh-add -l\n
expect $FPB 20000
# A's exit command ends the session both clients hold, so B's tile goes too and
# B is left on the same empty wall with the same picker over it. The
# legend and not a settle, doubly so here: B is a passive observer, so its
# screen is already quiet when A types exit and a duration could expire
# before the transition this waits for has happened at all.
expect c new session 20000
send \x1b
settle 300 20000
send \x1cd
waitexit 20000
EOF
RCB=$?
wait "$FLIPAPID"
RCA=$?
FLIPAPID=""
set -e
# A first: B's script waits on A's marker, so a B that failed downstream of a
# broken A would otherwise be the only thing reported.
#
# Two fingerprints named, not one, because A waits for BOTH — its own while
# it is the one typing, then B's once B is. Which of the two went missing is
# the whole diagnosis, and only the verb line in the log below can say it.
[ "$RCA" -eq 0 ] || {
echo "e2e FAIL: agent-flip: client A exited $RCA. It wants its own $FPA"
echo " while it types, then B's $FPB once B does:"
cat "$OUT.flipa.log"; exit 1; }
# The load-bearing one. B typed last, so B's agent had to answer B's dial —
# a daemon that pinned the answerer to the first offerer, or to the
# lowest-numbered slot, prints $FPA here and this is where it is caught.
[ "$RCB" -eq 0 ] || {
echo "e2e FAIL: agent-flip: client B exited $RCB — it typed last and wanted"
echo " its own agent's $FPB back, not A's $FPA:"
cat "$OUT.flipb.log"; exit 1; }
# No grep over the captures beside these two rcs, deliberately. A's script
# matched $FPA, then its own marker, then $FPB, on a cursor that only moves
# forward — the flip is already asserted IN ORDER, in one capture. And a
# grep for either fingerprint in either capture would pass on both clients
# whatever routing did: they replicate one grid, so each capture holds both
# answers by the time the session ends.
# A typed `exit` ended this daemon's only session and the daemon is still
# here: emptiness is not an exit, and only `mux d stop` ends one. So this is
# assert_stopped like every other teardown — the kill -0 that says the
# process really went is inside it (wait_pid_gone), and the empty daemon
# had to answer the stop to get there.
assert_stopped "$SOCK49" "$D43PID" "agent-flip" "$OUT.flipstop"
D43PID=""
# Ended here rather than in the trap, for the reason the leg above gives:
# a green run should not leave two key-holding processes alive for the
# length of the teardown. The trap still names them.
softkill "$AGENT49APID" || true
AGENT49APID=""
softkill "$AGENT49BPID" || true
AGENT49BPID=""
ok "agent forwarding: the agent that answers is whoever typed last"