a73x

0b356394

test: the signing agent follows whoever typed last

a73x   2026-08-21 08:33

Commit message
test: the signing agent follows whoever typed last

test/e2e.sh
Old New
@@ -438,6 +438,22 @@ AGENT48PID=""
438 AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$" 438 AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$"
439 D42PID="" 439 D42PID=""
440 440
441 # The flip leg gets a daemon of its own, and not for isolation's sake: the
442 # one above is stopped by its own scenario's assert_stopped, so there is no
443 # daemon left to reuse. Two agents, two keys, two sockets — one agent behind
444 # both clients would print the same fingerprint whichever slot answered, and
445 # the leg would pass having proved nothing. Beside the sockets for the
446 # sun_path reason above; these are dialled from inside a session too.
447 SOCK49="${TMPDIR:-/tmp}/muxd-e2e-agentflip-$$.sock"
448 AGENT49A="${TMPDIR:-/tmp}/mux-e2e-agent2a-$$.sock"
449 AGENT49B="${TMPDIR:-/tmp}/mux-e2e-agent2b-$$.sock"
450 AGENT49APID=""
451 AGENT49BPID=""
452 AGENT49AKEY="${TMPDIR:-/tmp}/mux-e2e-agent2akey-$$"
453 AGENT49BKEY="${TMPDIR:-/tmp}/mux-e2e-agent2bkey-$$"
454 D43PID=""
455 FLIPAPID=""
456
441 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 457 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
442 # such line on exit; every field is a key=value pair, so a rename or reorder 458 # such line on exit; every field is a key=value pair, so a rename or reorder
443 # in the client shows up here as an empty read rather than a wrong number. 459 # in the client shows up here as an empty read rather than a wrong number.
@@ -986,6 +1002,9 @@ cleanup() {
986 # a real terminal gives, which is why the fixture never reaps its own 1002 # a real terminal gives, which is why the fixture never reaps its own
987 # child and this trap does not chase one. 1003 # child and this trap does not chase one.
988 [ -n "$TP1PID" ] && kill "$TP1PID" 2>/dev/null || true 1004 [ -n "$TP1PID" ] && kill "$TP1PID" 2>/dev/null || true
1005 # The agent-flip leg's backgrounded client, on the same guarantee: its
1006 # own client dies with the pty master this kill closes.
1007 [ -n "$FLIPAPID" ] && kill "$FLIPAPID" 2>/dev/null || true
989 # Detached daemons: killed by the pid their own up-line reported. 1008 # Detached daemons: killed by the pid their own up-line reported.
990 [ -n "$SPID" ] && kill "$SPID" 2>/dev/null || true 1009 [ -n "$SPID" ] && kill "$SPID" 2>/dev/null || true
991 [ -n "$TPID" ] && kill "$TPID" 2>/dev/null || true 1010 [ -n "$TPID" ] && kill "$TPID" 2>/dev/null || true
@@ -1063,11 +1082,14 @@ cleanup() {
1063 [ -n "$D40PID" ] && kill "$D40PID" 2>/dev/null || true 1082 [ -n "$D40PID" ] && kill "$D40PID" 2>/dev/null || true
1064 [ -n "$D41PID" ] && kill "$D41PID" 2>/dev/null || true 1083 [ -n "$D41PID" ] && kill "$D41PID" 2>/dev/null || true
1065 [ -n "$D42PID" ] && kill "$D42PID" 2>/dev/null || true 1084 [ -n "$D42PID" ] && kill "$D42PID" 2>/dev/null || true
1066 # The ssh-agent the forwarding legs start. Not a mux process and so not 1085 [ -n "$D43PID" ] && kill "$D43PID" 2>/dev/null || true
1067 # the leak sweep's business, but it is a daemon this file forked: left 1086 # The ssh-agents the forwarding legs start. Not mux processes and so not
1068 # alive it outlives the suite holding a private key, which is the one 1087 # the leak sweep's business, but they are daemons this file forked: left
1088 # alive they outlive the suite holding a private key, which is the one
1069 # kind of leak worth chasing even on a green run. 1089 # kind of leak worth chasing even on a green run.
1070 [ -n "$AGENT48PID" ] && kill "$AGENT48PID" 2>/dev/null || true 1090 [ -n "$AGENT48PID" ] && kill "$AGENT48PID" 2>/dev/null || true
1091 [ -n "$AGENT49APID" ] && kill "$AGENT49APID" 2>/dev/null || true
1092 [ -n "$AGENT49BPID" ] && kill "$AGENT49BPID" 2>/dev/null || true
1071 # The stops still precede the socket rm below, like SOCK14-17 above: 1093 # The stops still precede the socket rm below, like SOCK14-17 above:
1072 # unlinking a socket first would leave a live daemon nothing could reach 1094 # unlinking a socket first would leave a live daemon nothing could reach
1073 # by path. 1095 # by path.
@@ -1098,6 +1120,7 @@ cleanup() {
1098 [ -S "$SOCK46" ] && "$MUXD" stop --sock "$SOCK46" 2>/dev/null || true 1120 [ -S "$SOCK46" ] && "$MUXD" stop --sock "$SOCK46" 2>/dev/null || true
1099 [ -S "$SOCK47" ] && "$MUXD" stop --sock "$SOCK47" 2>/dev/null || true 1121 [ -S "$SOCK47" ] && "$MUXD" stop --sock "$SOCK47" 2>/dev/null || true
1100 [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true 1122 [ -S "$SOCK48" ] && "$MUXD" stop --sock "$SOCK48" 2>/dev/null || true
1123 [ -S "$SOCK49" ] && "$MUXD" stop --sock "$SOCK49" 2>/dev/null || true
1101 1124
1102 # ---- the leak sweep (hygiene kit, 6a) ---- 1125 # ---- the leak sweep (hygiene kit, 6a) ----
1103 # Here rather than at the bottom of the file, which `set -e` reaches only 1126 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -1112,7 +1135,7 @@ cleanup() {
1112 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \ 1135 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \
1113 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \ 1136 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \
1114 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \ 1137 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \
1115 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" 1138 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID"
1116 _leak=0 1139 _leak=0
1117 leak_sweep "$_rc" || _leak=1 1140 leak_sweep "$_rc" || _leak=1
1118 1141
@@ -1278,6 +1301,11 @@ cleanup() {
1278 rm -f "$SOCK48" "$AGENT48" "$AGENT48KEY" "$AGENT48KEY.pub" \ 1301 rm -f "$SOCK48" "$AGENT48" "$AGENT48KEY" "$AGENT48KEY.pub" \
1279 "$OUT.agt.d" "$OUT.agt.env" "$OUT.agt" "$OUT.agt.err" "$OUT.agt.log" \ 1302 "$OUT.agt.d" "$OUT.agt.env" "$OUT.agt" "$OUT.agt.err" "$OUT.agt.log" \
1280 "$OUT.agtn" "$OUT.agtn.err" "$OUT.agtn.log" "$OUT.agtstop" 1303 "$OUT.agtn" "$OUT.agtn.err" "$OUT.agtn.log" "$OUT.agtstop"
1304 rm -f "$SOCK49" "$AGENT49A" "$AGENT49B" \
1305 "$AGENT49AKEY" "$AGENT49AKEY.pub" "$AGENT49BKEY" "$AGENT49BKEY.pub" \
1306 "$OUT.flip.d" \
1307 "$OUT.flipa.env" "$OUT.flipa" "$OUT.flipa.err" "$OUT.flipa.log" \
1308 "$OUT.flipb.env" "$OUT.flipb" "$OUT.flipb.err" "$OUT.flipb.log"
1281 # The convergence files a FAILING assert_converged leaves behind 1309 # The convergence files a FAILING assert_converged leaves behind
1282 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 1310 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
1283 # chased here: on a failing run they are the evidence. 1311 # chased here: on a failing run they are the evidence.
@@ -7077,6 +7105,159 @@ AGENT48PID=""
7077 ok "agent forwarding: no offerer means a fast refusal, not a hang" 7105 ok "agent forwarding: no offerer means a fast refusal, not a hang"
7078 7106
7079 7107
7108 # --- ...and the agent that answers is whoever typed last -------------------
7109 #
7110 # Two clients on ONE session, each offering its OWN ssh-agent holding its
7111 # OWN key. `ssh-add -l` typed at one keyboard and then at the other has to
7112 # come back with two DIFFERENT fingerprints — that is the spec's "latest
7113 # wins, applied to keys", and it is a claim only two agents can make.
7114 "$MUXD" run --sock "$SOCK49" --shell /bin/sh > "$OUT.flip.d" 2>&1 &
7115 D43PID=$!
7116 wait_sock "$SOCK49" "$OUT.flip.d" "agent-flip daemon never bound"
7117
7118 # Both agents after the daemon, for the ordering reason the leg above gives:
7119 # a path the daemon cannot have inherited is what makes a fingerprint
7120 # evidence of a forward rather than of a leak.
7121 ssh-agent -a "$AGENT49A" > "$OUT.flipa.env" 2>&1
7122 AGENT49APID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.flipa.env")
7123 ssh-agent -a "$AGENT49B" > "$OUT.flipb.env" 2>&1
7124 AGENT49BPID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.flipb.env")
7125 [ -n "$AGENT49APID" ] && [ -n "$AGENT49BPID" ] || {
7126 echo "e2e FAIL: agent-flip: an ssh-agent printed no pid for the trap to hold"
7127 cat "$OUT.flipa.env" "$OUT.flipb.env"; exit 1; }
7128 # -C for the wrap reason the leg above gives, and two different comments so
7129 # a capture holding the wrong answer says out loud whose it is.
7130 ssh-keygen -q -t ed25519 -N '' -C mux-e2e-flip-a -f "$AGENT49AKEY"
7131 ssh-keygen -q -t ed25519 -N '' -C mux-e2e-flip-b -f "$AGENT49BKEY"
7132 SSH_AUTH_SOCK="$AGENT49A" ssh-add "$AGENT49AKEY" > /dev/null 2>&1
7133 SSH_AUTH_SOCK="$AGENT49B" ssh-add "$AGENT49BKEY" > /dev/null 2>&1
7134 FPA=$(ssh-keygen -lf "$AGENT49AKEY" | awk '{print $2}')
7135 FPB=$(ssh-keygen -lf "$AGENT49BKEY" | awk '{print $2}')
7136 for _fp in "$FPA" "$FPB"; do
7137 case "$_fp" in
7138 SHA256:?*) ;;
7139 *) echo "e2e FAIL: agent-flip: ssh-keygen -lf gave no fingerprint to look for: [$_fp]"
7140 exit 1;;
7141 esac
7142 done
7143 # Never expected, and asserted anyway, because it is the one failure this
7144 # leg cannot see: two keys that hashed alike would satisfy both expects
7145 # below without anything having flipped.
7146 [ "$FPA" != "$FPB" ] || {
7147 echo "e2e FAIL: agent-flip: both agents hold the same fingerprint [$FPA] —"
7148 echo " no expect below could tell one answerer from the other"
7149 exit 1; }
7150
7151 # The sequencing, and why it needs no sleeps.
7152 #
7153 # `agentAnswerer` (server.zig) ranks the session's offerers by
7154 # ClientSlot.activity and reads that ranking ONCE, when ssh-add's dial is
7155 # accepted. Activity is bumped by three verbs only — attach, input, resize
7156 # (bumpActivity) — and the input bump happens as the frame is handled,
7157 # strictly before those bytes reach the pty. So a client's own query is
7158 # always the last bump before its own dial, PROVIDED nobody else attaches or
7159 # types in between. That proviso is the entire choreography:
7160 #
7161 # the second attach is a bump too, so A cannot simply query first and hope
7162 # B has not arrived yet. Instead B announces itself by TYPING and A waits
7163 # for that line to reach its own screen. A's query is then provably after
7164 # B's attach AND after B's only keystroke, because A did not type until
7165 # B's bytes had crossed the daemon, the pty and back;
7166 #
7167 # the baton back is the same trick mirrored: A types a marker after its
7168 # answer, B is watching the same grid and types only once it lands, so B's
7169 # bump is provably after A's and B's dial is routed to B.
7170 #
7171 # Which also makes the leg independent of which client attaches first — the
7172 # order is fixed by typed markers, not by process start.
7173 #
7174 # Both markers are matched on the shell's ECHO, and that is sound here in a
7175 # way it would not be for an assertion: the M12 echo-proof rule guards
7176 # needles that claim something crossed the WIRE, and a marker claims only
7177 # that a keystroke was handled — which is precisely what an echo witnesses,
7178 # and witnesses earlier than any command output could.
7179 #
7180 # The fingerprints are not markers and are echo-proof by that same rule:
7181 # `ssh-add -l` cannot spell one.
7182 #
7183 # Unquoted heredocs, like the positive leg's: $FPA and $FPB have to
7184 # interpolate, and nothing else in either script needs expanding.
7185 set +e
7186 SSH_AUTH_SOCK="$AGENT49A" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
7187 --out "$OUT.flipa" --err "$OUT.flipa.err" \
7188 -- "$MUX" -A --sock "$SOCK49" > "$OUT.flipa.log" 2>&1 <<EOF &
7189 expect \x1b[?1049h 15000
7190 expect b-ready 25000
7191 send ssh-add -l\n
7192 expect $FPA 20000
7193 send echo flip-now\n
7194 expect $FPB 25000
7195 send exit\n
7196 waitexit 20000
7197 EOF
7198 FLIPAPID=$!
7199 # B settles before its first keystroke and A does not need to: a shell still
7200 # setting up its terminal can flush what was typed at it, and B's marker is
7201 # the only line typed before any shell has spoken. Every later send waits on
7202 # a marker, which is a barrier a settle could only approximate.
7203 SSH_AUTH_SOCK="$AGENT49B" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
7204 --out "$OUT.flipb" --err "$OUT.flipb.err" \
7205 -- "$MUX" -A --sock "$SOCK49" > "$OUT.flipb.log" 2>&1 <<EOF
7206 expect \x1b[?1049h 15000
7207 settle 400 15000
7208 send echo b-ready\n
7209 expect flip-now 30000
7210 send ssh-add -l\n
7211 expect $FPB 20000
7212 waitexit 20000
7213 EOF
7214 RCB=$?
7215 wait "$FLIPAPID"
7216 RCA=$?
7217 FLIPAPID=""
7218 set -e
7219 # A first: B's script waits on A's marker, so a B that failed downstream of a
7220 # broken A would otherwise be the only thing reported.
7221 #
7222 # Two fingerprints named, not one, because A waits for BOTH — its own while
7223 # it is the one typing, then B's once B is. Which of the two went missing is
7224 # the whole diagnosis, and only the verb line in the log below can say it.
7225 [ "$RCA" -eq 0 ] || {
7226 echo "e2e FAIL: agent-flip: client A exited $RCA. It wants its own $FPA"
7227 echo " while it types, then B's $FPB once B does:"
7228 cat "$OUT.flipa.log"; exit 1; }
7229 # The load-bearing one. B typed last, so B's agent had to answer B's dial —
7230 # a daemon that pinned the answerer to the first offerer, or to the
7231 # lowest-numbered slot, prints $FPA here and this is where it is caught.
7232 [ "$RCB" -eq 0 ] || {
7233 echo "e2e FAIL: agent-flip: client B exited $RCB — it typed last and wanted"
7234 echo " its own agent's $FPB back, not A's $FPA:"
7235 cat "$OUT.flipb.log"; exit 1; }
7236 # No grep over the captures beside these two rcs, deliberately. A's script
7237 # matched $FPA, then its own marker, then $FPB, on a cursor that only moves
7238 # forward — the flip is already asserted IN ORDER, in one capture. And a
7239 # grep for either fingerprint in either capture would pass on both clients
7240 # whatever routing did: they replicate one grid, so each capture holds both
7241 # answers by the time the session ends.
7242
7243 # wait_pid_gone and not assert_stopped, because there is nothing left to
7244 # stop: A typed `exit`, that was this daemon's only session, and a daemon
7245 # leaves with its last one (server.zig pumpOnce). The positive leg above
7246 # ends on assert_stopped only because its second scenario auto-started a
7247 # fresh daemon on the same path to stop. What is asserted here is the same
7248 # thing either way — the process this leg forked is gone.
7249 wait_pid_gone "$D43PID" "agent-flip: the session ended and the daemon should follow"
7250 D43PID=""
7251 # Ended here rather than in the trap, for the reason the leg above gives:
7252 # a green run should not leave two key-holding processes alive for the
7253 # length of the teardown. The trap still names them.
7254 kill "$AGENT49APID" 2>/dev/null || true
7255 AGENT49APID=""
7256 kill "$AGENT49BPID" 2>/dev/null || true
7257 AGENT49BPID=""
7258 ok "agent forwarding: the agent that answers is whoever typed last"
7259
7260
7080 # The long-lived daemon has served every scenario that wanted it; stop it 7261 # The long-lived daemon has served every scenario that wanted it; stop it
7081 # NOW so its allocator verdict is written while the suite is still running 7262 # NOW so its allocator verdict is written while the suite is still running
7082 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain 7263 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain
@@ -7178,9 +7359,12 @@ DPID=""
7178 # establishes and this one does not doubt. The 57th is its negative twin, 7359 # establishes and this one does not doubt. The 57th is its negative twin,
7179 # and no convergence point for the same reason plus the exit-code reason: 7360 # and no convergence point for the same reason plus the exit-code reason:
7180 # what it asserts on is a shell's `$?` and a wall-clock bound, and a grid 7361 # what it asserts on is a shell's `$?` and a wall-clock bound, and a grid
7181 # comparison speaks to neither. 7362 # comparison speaks to neither. The 58th is the two-agent flip, and no
7182 [ "$OK_COUNT" = "57" ] || { 7363 # convergence point because its subject is which of two clients' terminals
7183 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 57 —" 7364 # a fingerprint was answered FOR — a difference the two grids do not carry,
7365 # since both replicate the same session and hold both answers alike.
7366 [ "$OK_COUNT" = "58" ] || {
7367 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 58 —"
7184 echo " a scenario was added (update the pin) or silently lost" 7368 echo " a scenario was added (update the pin) or silently lost"
7185 exit 1 7369 exit 1
7186 } 7370 }
@@ -7188,4 +7372,4 @@ DPID=""
7188 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 7372 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
7189 exit 1 7373 exit 1
7190 } 7374 }
7191 echo "e2e OK (57 scenarios, 35 convergence points)" 7375 echo "e2e OK (58 scenarios, 35 convergence points)"