0b356394
test: the signing agent follows whoever typed last
a73x 2026-08-21 08:33
Commit message
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)" |