a73x

fbfd75df

test: 10_agent moves to a file of its own

a73x   2026-08-26 19:12

Commit message
test: 10_agent moves to a file of its own

agent forwarding: a real agent, a real key, a real refusal — 5 scenarios, 461 lines. The legs move unedited; the runner
sources the file where they used to stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

test/e2e.sh
Old New
@@ -102,7 +102,7 @@ done
102 E2E_DIR=$(dirname "$0") 102 E2E_DIR=$(dirname "$0")
103 . "$E2E_DIR/e2e_lib.sh" 103 . "$E2E_DIR/e2e_lib.sh"
104 104
105 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_wallhist' 105 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_wallhist 10_agent'
106 # The groups that cannot stand alone, and what they need. E2E_ONLY refuses 106 # The groups that cannot stand alone, and what they need. E2E_ONLY refuses
107 # these by name rather than running them: a group whose fixtures are 107 # these by name rather than running them: a group whose fixtures are
108 # missing does not fail, it passes having tested something else — and a 108 # missing does not fail, it passes having tested something else — and a
@@ -145,464 +145,6 @@ for _g in $E2E_GROUPS; do
145 . "$E2E_DIR/e2e_$_g.sh" 145 . "$E2E_DIR/e2e_$_g.sh"
146 done 146 done
147 147
148 # Agent forwarding: one socket path, two daemons in turn. The positive leg
149 # ends by typing `exit`, which ends that daemon's only session and takes the
150 # daemon with it — so the refusal leg below starts its own on the freed path
151 # rather than inheriting one that is already gone. Both are $MUXD; neither
152 # may be auto-started (see the $PATH note at the top of this file).
153 #
154 # The agent lives beside the sockets rather than under $OUT: a unix path is
155 # capped at 107 bytes by sun_path, and this one is dialled from inside a
156 # session shell, so the room it has is the room every other socket in this
157 # file was given.
158 SOCK48="${TMPDIR:-/tmp}/muxd-e2e-agentfwd-$$.sock"
159 defer_sock "$SOCK48"
160 AGENT48="${TMPDIR:-/tmp}/mux-e2e-agent-$$.sock"
161 defer_rm "$AGENT48"
162 AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$"
163 defer_rm "$AGENT48KEY" "$AGENT48KEY.pub"
164 # The flip leg gets a daemon of its own, and not for isolation's sake: the
165 # one above is stopped by its own scenario's assert_stopped, so there is no
166 # daemon left to reuse. Two agents, two keys, two sockets — one agent behind
167 # both clients would print the same fingerprint whichever slot answered, and
168 # the leg would pass having proved nothing. Beside the sockets for the
169 # sun_path reason above; these are dialled from inside a session too.
170 SOCK49="${TMPDIR:-/tmp}/muxd-e2e-agentflip-$$.sock"
171 defer_sock "$SOCK49"
172 AGENT49A="${TMPDIR:-/tmp}/mux-e2e-agent2a-$$.sock"
173 defer_rm "$AGENT49A"
174 AGENT49B="${TMPDIR:-/tmp}/mux-e2e-agent2b-$$.sock"
175 defer_rm "$AGENT49B"
176 AGENT49AKEY="${TMPDIR:-/tmp}/mux-e2e-agent2akey-$$"
177 defer_rm "$AGENT49AKEY" "$AGENT49AKEY.pub"
178 AGENT49BKEY="${TMPDIR:-/tmp}/mux-e2e-agent2bkey-$$"
179 defer_rm "$AGENT49BKEY" "$AGENT49BKEY.pub"
180
181 # --- agent forwarding: a real key in the client's agent answers in the session
182 #
183 # Everything under this heading is a real program doing its real job: a real
184 # ssh-agent holding a real ed25519 key, a real `ssh-add -l` typed into a real
185 # session shell on a real pty. Nothing here stands in for anything, and that
186 # is the point — the daemon and the client both carry the agent protocol
187 # BLIND (server.zig's serviceAgentChan copies bytes it never parses), so a
188 # stand-in agent would be this suite grading the pump against a second guess
189 # at a format neither end reads. openssh either recognises what came back or
190 # it does not, and that answer is the only one worth having.
191 #
192 # The needle is a SHA256 fingerprint, and it is echo-proof by the M12 rule
193 # (tp2, argued at the OSC 52 leg above): the shell echoes what is typed, so
194 # a needle a keystroke could spell would pass on a client that forwarded
195 # nothing at all. The typed line is `ssh-add -l`, which cannot spell a
196 # fingerprint. What puts those 50 bytes on the terminal is the agent on this
197 # machine answering a request that left the session, crossed the daemon,
198 # reached the client, and came back.
199 start_daemon "$SOCK48" "$OUT.agt.d" "agent daemon never bound" --shell /bin/sh
200 D42PID=$DPID
201
202 # The agent is started AFTER the daemon and its path is never exported, so
203 # the daemon cannot have inherited it: the only SSH_AUTH_SOCK a session on
204 # this daemon can hold is one the daemon bound itself. That ordering is what
205 # makes the fingerprint below evidence of a forward rather than of a leak.
206 ssh-agent -a "$AGENT48" > "$OUT.agt.env" 2>&1
207 AGENT48PID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.agt.env")
208 defer_kill "$AGENT48PID"
209 [ -n "$AGENT48PID" ] || {
210 echo "e2e FAIL: agent: ssh-agent printed no pid for the trap to hold it by:"
211 cat "$OUT.agt.env"; exit 1; }
212 # -C, so `ssh-add -l` prints a short comment instead of this key's whole
213 # path: the answer has to fit one row of the terminal below, and a line that
214 # wrapped could split the fingerprint across two rows the capture never
215 # joins back up.
216 ssh-keygen -q -t ed25519 -N '' -C mux-e2e-agent -f "$AGENT48KEY"
217 SSH_AUTH_SOCK="$AGENT48" ssh-add "$AGENT48KEY" > /dev/null 2>&1
218 FP48=$(ssh-keygen -lf "$AGENT48KEY" | awk '{print $2}')
219 # An empty or malformed needle would reach ptyclient as a usage error rather
220 # than as a failed expectation, and the suite would report the wrong thing.
221 case "$FP48" in
222 SHA256:?*) ;;
223 *) echo "e2e FAIL: agent: ssh-keygen -lf gave no fingerprint to look for: [$FP48]"
224 exit 1;;
225 esac
226
227 # Unquoted heredoc, like the OSC 52 leg's and for the same reason: $FP48 has
228 # to interpolate. Nothing else in the script needs expanding — `\n` and
229 # `\x1b` are not escapes the shell touches.
230 set +e
231 SSH_AUTH_SOCK="$AGENT48" timeout 40 "$PTYCLIENT" --cols 100 --rows 30 \
232 --out "$OUT.agt" --err "$OUT.agt.err" \
233 -- "$MUX" -A --sock "$SOCK48" > "$OUT.agt.log" 2>&1 <<EOF
234 expect \x1b[?1049h 15000
235 settle 400 15000
236 send ssh-add -l\n
237 expect $FP48 15000
238 settle 400 15000
239 send exit\n
240 waitexit 10000
241 EOF
242 RC=$?
243 set -e
244 # Only the fixture's log, not the capture beside it: ptyclient prints the
245 # verb that failed and the last 200 bytes it saw, which is the answer. The
246 # capture is a screenful of escapes that would bury it.
247 [ "$RC" -eq 0 ] || {
248 echo "e2e FAIL: agent: the client's key never answered ssh-add -l in the"
249 echo " session (wanted $FP48):"
250 cat "$OUT.agt.log"; exit 1; }
251 ok "agent forwarding: ssh-add -l in the session lists the client's key"
252
253 # --- ...and `-A` with nothing behind it is refused before the attach ------
254 #
255 # The flag is a promise the client cannot keep with no agent running, and it
256 # used to be kept silently: the offer is a declaration rather than a
257 # capability, so every dial the session made was refused without a word and
258 # the user met it three layers down as `permission denied (publickey)` from
259 # a git remote. Refused at usage-error altitude now, exit 2, before the
260 # transport is touched.
261 #
262 # Both spellings of "no agent", because reading the variable would only
263 # catch the first: unset, and set to a path nothing answers on. The unit
264 # test covers the subtler third — an agent killed leaving its socket file
265 # behind, which stats fine and refuses the connect — since arranging a
266 # listener that dies mid-leg buys nothing here.
267 set +e
268 env -u SSH_AUTH_SOCK "$MUX" -A --sock "$SOCK48" > "$OUT.anoag" 2>&1
269 RC=$?
270 set -e
271 [ "$RC" -eq 2 ] || {
272 echo "e2e FAIL: agent: -A with no SSH_AUTH_SOCK exited $RC, want 2:"
273 cat "$OUT.anoag"; exit 1; }
274 grep -q "is not set" "$OUT.anoag" || {
275 echo "e2e FAIL: agent: -A with no SSH_AUTH_SOCK refused for another reason:"
276 cat "$OUT.anoag"; exit 1; }
277
278 set +e
279 SSH_AUTH_SOCK="$OUT.dead-agent.sock" "$MUX" -A --sock "$SOCK48" > "$OUT.anoag2" 2>&1
280 RC=$?
281 set -e
282 [ "$RC" -eq 2 ] || {
283 echo "e2e FAIL: agent: -A at a dead agent path exited $RC, want 2:"
284 cat "$OUT.anoag2"; exit 1; }
285 grep -q "no ssh-agent answering at" "$OUT.anoag2" || {
286 echo "e2e FAIL: agent: -A at a dead agent path refused for another reason:"
287 cat "$OUT.anoag2"; exit 1; }
288
289 # The control: the SAME command with the SAME live agent the leg above used
290 # gets PAST the preflight, so the two refusals are about the agent and not
291 # about the flag being rejected outright. Bounded and its exit ignored —
292 # past the preflight it goes on to attach for real, which without a
293 # terminal is neither a pass nor a failure, only slow.
294 set +e
295 SSH_AUTH_SOCK="$AGENT48" timeout 5 "$MUX" -A --sock "$SOCK48" > "$OUT.anoag3" 2>&1
296 set -e
297 # `if`, not `grep && {...}`: under `set -e` an AND-list ending in a failed
298 # grep takes the whole suite down, and here a failed grep is the PASS.
299 if grep -q "no ssh-agent" "$OUT.anoag3"; then
300 echo "e2e FAIL: agent: a REACHABLE agent was still refused by the preflight:"
301 cat "$OUT.anoag3"; exit 1
302 fi
303 ok "agent forwarding: -A with no agent is a usage error, not a silent no-op"
304
305 # --- ...and a session nobody offered an agent to refuses, fast -------------
306 #
307 # A fresh daemon on the freed path, and the spawn is load-bearing: the leg
308 # above took its daemon down with its last session, so a client dialling
309 # $SOCK48 now would AUTO-START one off $PATH instead — a daemon that need
310 # not be the one under test, and against a release with no agent code in it
311 # every assertion below passes having witnessed nothing.
312 #
313 # The sibling case — a session beside one that HAS an answerer — is the unit
314 # suite's; what this leg witnesses is the plainer shape, a live daemon
315 # holding a single session nobody has offered an agent to.
316 #
317 # SSH_AUTH_SOCK is still set inside that session — every session is born
318 # with one, whether or not anyone has offered — and it points at the daemon,
319 # which accepts the dial and closes it. That is the designed answer, not a
320 # gap: ssh reads a closed agent socket as "agent refused operation" and
321 # falls straight through to its other methods, where a connection accepted
322 # and left silent would make it wait out a timeout on every dial.
323 wait_pid_gone "$D42PID" "agent forwarding: the positive leg's daemon outlived its last session"
324 start_daemon "$SOCK48" "$OUT.agtn.d" "agent-refusal daemon never bound" --shell /bin/sh
325 D42PID=$DPID
326 AR0=$(date +%s%N)
327 set +e
328 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.agtn" --err "$OUT.agtn.err" \
329 -- "$MUX" --sock "$SOCK48" --session noagent > "$OUT.agtn.log" 2>&1 <<'EOF'
330 expect \x1b[?1049h 15000
331 settle 400 15000
332 send echo agtsock=${SSH_AUTH_SOCK:+present}\n
333 expect agtsock=present 15000
334 send ssh-add -l; echo agtrc=$?\n
335 expect agtrc= 15000
336 settle 400 15000
337 send exit\n
338 waitexit 10000
339 EOF
340 RC=$?
341 set -e
342 AR1=$(date +%s%N)
343 AMS=$(( (AR1 - AR0) / 1000000 ))
344 [ "$RC" -eq 0 ] || {
345 echo "e2e FAIL: agent-refusal: the leg exited $RC:"
346 cat "$OUT.agtn.log"; exit 1; }
347 # Quoted heredoc, unlike the leg above: `$?` has to reach the SESSION's
348 # shell as two characters, not be spent by this one.
349 #
350 # `expect agtsock=present` is the leg's non-vacuity pin, and unlike the
351 # needle below it IS an assertion: the typed line spells the parameter
352 # expansion, so only a shell that ran it can put the word `present` on its
353 # own line. Everything else here would pass just as well against a daemon
354 # that had never heard of agents — no SSH_AUTH_SOCK at all also means no
355 # key, no signature and a nonzero status. This verb is what separates
356 # "accepted the dial and closed it" from "nothing was ever offered".
357 #
358 # `expect agtrc=` is a wait and not an assertion — the shell echoes the line
359 # it was typed, so that needle matches before ssh-add has run. The settle
360 # after it is what waits for the answer; these two greps are the assertion,
361 # and both are echo-proof, because the typed line spells `agtrc=$?` and
362 # neither `agtrc=0` nor a digit can come from echoing it.
363 grep -q "agtrc=0" "$OUT.agtn" && {
364 echo "e2e FAIL: agent-refusal: ssh-add succeeded in a session nobody offered"
365 echo " an agent to — something answered for a key that is not there:"
366 cat -v "$OUT.agtn"; exit 1; }
367 grep -qE "agtrc=[1-9]" "$OUT.agtn" || {
368 echo "e2e FAIL: agent-refusal: ssh-add never reported a status at all — the"
369 echo " session did not run it, so its failure proves nothing:"
370 cat -v "$OUT.agtn"; exit 1; }
371 # Ceiling only, and the floor's absence is the claim: a refusal is a close on
372 # an already-accepted connection and should cost about nothing, so there is
373 # no lower bound worth defending. What the ceiling owns is the band this
374 # leg's own deadlines cannot see. A refusal that HUNG is caught by the
375 # waitexit above and reported as an exit code; a refusal that came back
376 # after ten seconds of retrying is not — `expect` matched the echo long
377 # before, and the settle would sit through the whole retry and still pass.
378 # This bound is what makes that band a failure.
379 #
380 # 10000 for the reason the key-mismatch leg above picked the same number: it
381 # separates "refused" from "sat in a loop" and is not asked to do anything
382 # finer. The whole leg — attach, two 400ms settles, the agtsock round trip,
383 # a shell's startup and the refusal itself — measured 816-837ms across the
384 # grading runs against the daemon started above, so a tighter ceiling would
385 # catch nothing this one misses while making a check that is nobody's
386 # bottleneck the suite's most load-sensitive one.
387 [ "$AMS" -lt 10000 ] || {
388 echo "e2e FAIL: agent-refusal: the leg took ${AMS}ms — a dial with nobody to"
389 echo " answer it must be refused, not retried"
390 exit 1; }
391
392 # --- ...and a nested `-A` inside such a session is refused, not passed -----
393 #
394 # The preflight's whole job is to refuse a client that cannot answer a
395 # challenge. Inside a session `SSH_AUTH_SOCK` names the daemon, which
396 # accepts every dial and only afterwards looks for an offerer to route it
397 # to — so the connect the preflight used to do succeeded there, and the
398 # nested client attached as an offerer that can answer nothing. Worse, it
399 # out-ranks a working `-A` client, because the answerer is the latest-active
400 # OFFERER and offering is a declaration.
401 #
402 # `--session noagent` is the session this shell is already in, deliberately:
403 # the preflight runs before any transport work, so it is what must answer
404 # here, and the self-attach refusal standing behind it means a regression
405 # fails this leg instead of spawning a nested attach into the pty. Which of
406 # the two spoke is what the message grep decides; the exit code alone
407 # cannot, since both refusals use 2.
408 #
409 # Unquoted heredoc, unlike the leg above: $MUX and $SOCK48 have to be
410 # expanded by THIS shell, so `$?` is escaped to reach the session's.
411 set +e
412 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.agtnest" --err "$OUT.agtnest.err" \
413 -- "$MUX" --sock "$SOCK48" --session noagent > "$OUT.agtnest.log" 2>&1 <<EOF
414 expect \x1b[?1049h 15000
415 settle 400 15000
416 send $MUX -A --sock $SOCK48 --session noagent; echo nest=\$?\n
417 settle 1500 15000
418 send exit\n
419 waitexit 10000
420 EOF
421 RC=$?
422 set -e
423 [ "$RC" -eq 0 ] || {
424 echo "e2e FAIL: agent-nested: the leg exited $RC:"
425 cat "$OUT.agtnest.log"; exit 1; }
426 # Both needles are echo-proof: the typed line spells `nest=$?` and says
427 # nothing about ssh-agents, so neither `nest=2` nor the message below can
428 # come from the shell echoing what it was sent.
429 grep -q "nest=2" "$OUT.agtnest" || {
430 echo "e2e FAIL: agent-nested: a nested \`mux -A\` did not exit 2 — the"
431 echo " preflight let through a client with no agent behind it:"
432 cat -v "$OUT.agtnest"; exit 1; }
433 grep -q "no ssh-agent answering at" "$OUT.agtnest" || {
434 echo "e2e FAIL: agent-nested: the nested \`mux -A\` was refused, but not by"
435 echo " the agent preflight — something else answered first:"
436 cat -v "$OUT.agtnest"; exit 1; }
437 ok "agent forwarding: a nested -A with nobody offering is refused"
438
439 assert_stopped "$SOCK48" "$D42PID" "agent forwarding" "$OUT.agtstop"
440 D42PID=""
441 # The agent has no more work; ended here rather than in the trap so a green
442 # run does not leave a key-holding process alive for the length of the
443 # teardown. The trap still names it, for every run that does not reach here.
444 softkill "$AGENT48PID" || true
445 AGENT48PID=""
446 # The measurement is printed on a PASSING run too, not just quoted in the
447 # failure above: the band this leg's ceiling was chosen against lives in
448 # docs/decisions.md, and a number nobody can see on a green run is a number
449 # that quietly stops being true.
450 ok "agent forwarding: no offerer means a fast refusal, not a hang (${AMS}ms)"
451
452 # --- ...and the agent that answers is whoever typed last -------------------
453 #
454 # Two clients on ONE session, each offering its OWN ssh-agent holding its
455 # OWN key. `ssh-add -l` typed at one keyboard and then at the other has to
456 # come back with two DIFFERENT fingerprints — that is the spec's "latest
457 # wins, applied to keys", and it is a claim only two agents can make.
458 start_daemon "$SOCK49" "$OUT.flip.d" "agent-flip daemon never bound" --shell /bin/sh
459 D43PID=$DPID
460
461 # Both agents after the daemon, for the ordering reason the leg above gives:
462 # a path the daemon cannot have inherited is what makes a fingerprint
463 # evidence of a forward rather than of a leak.
464 ssh-agent -a "$AGENT49A" > "$OUT.flipa.env" 2>&1
465 AGENT49APID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.flipa.env")
466 defer_kill "$AGENT49APID"
467 ssh-agent -a "$AGENT49B" > "$OUT.flipb.env" 2>&1
468 AGENT49BPID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.flipb.env")
469 defer_kill "$AGENT49BPID"
470 [ -n "$AGENT49APID" ] && [ -n "$AGENT49BPID" ] || {
471 echo "e2e FAIL: agent-flip: an ssh-agent printed no pid for the trap to hold"
472 cat "$OUT.flipa.env" "$OUT.flipb.env"; exit 1; }
473 # -C for the wrap reason the leg above gives, and two different comments so
474 # a capture holding the wrong answer says out loud whose it is.
475 ssh-keygen -q -t ed25519 -N '' -C mux-e2e-flip-a -f "$AGENT49AKEY"
476 ssh-keygen -q -t ed25519 -N '' -C mux-e2e-flip-b -f "$AGENT49BKEY"
477 SSH_AUTH_SOCK="$AGENT49A" ssh-add "$AGENT49AKEY" > /dev/null 2>&1
478 SSH_AUTH_SOCK="$AGENT49B" ssh-add "$AGENT49BKEY" > /dev/null 2>&1
479 FPA=$(ssh-keygen -lf "$AGENT49AKEY" | awk '{print $2}')
480 FPB=$(ssh-keygen -lf "$AGENT49BKEY" | awk '{print $2}')
481 for _fp in "$FPA" "$FPB"; do
482 case "$_fp" in
483 SHA256:?*) ;;
484 *) echo "e2e FAIL: agent-flip: ssh-keygen -lf gave no fingerprint to look for: [$_fp]"
485 exit 1;;
486 esac
487 done
488 # Never expected, and asserted anyway, because it is the one failure this
489 # leg cannot see: two keys that hashed alike would satisfy both expects
490 # below without anything having flipped.
491 [ "$FPA" != "$FPB" ] || {
492 echo "e2e FAIL: agent-flip: both agents hold the same fingerprint [$FPA] —"
493 echo " no expect below could tell one answerer from the other"
494 exit 1; }
495
496 # The sequencing, and why it needs no sleeps.
497 #
498 # `agentAnswerer` (server.zig) ranks the session's offerers by
499 # ClientSlot.activity and reads that ranking ONCE, when ssh-add's dial is
500 # accepted. Activity is bumped by three verbs only — attach, input, resize
501 # (bumpActivity) — and the input bump happens as the frame is handled,
502 # strictly before those bytes reach the pty. So a client's own query is
503 # always the last bump before its own dial, PROVIDED nobody else attaches or
504 # types in between. That proviso is the entire choreography:
505 #
506 # the second attach is a bump too, so A cannot simply query first and hope
507 # B has not arrived yet. Instead B announces itself by TYPING and A waits
508 # for that line to reach its own screen. A's query is then provably after
509 # B's attach AND after B's only keystroke, because A did not type until
510 # B's bytes had crossed the daemon, the pty and back;
511 #
512 # the baton back is the same trick mirrored: A types a marker after its
513 # answer, B is watching the same grid and types only once it lands, so B's
514 # bump is provably after A's and B's dial is routed to B.
515 #
516 # Which also makes the leg independent of which client attaches first — the
517 # order is fixed by typed markers, not by process start.
518 #
519 # Both markers are matched on the shell's ECHO, and that is sound here in a
520 # way it would not be for an assertion: the M12 echo-proof rule guards
521 # needles that claim something crossed the WIRE, and a marker claims only
522 # that a keystroke was handled — which is precisely what an echo witnesses,
523 # and witnesses earlier than any command output could.
524 #
525 # The fingerprints are not markers and are echo-proof by that same rule:
526 # `ssh-add -l` cannot spell one.
527 #
528 # Unquoted heredocs, like the positive leg's: $FPA and $FPB have to
529 # interpolate, and nothing else in either script needs expanding.
530 set +e
531 SSH_AUTH_SOCK="$AGENT49A" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
532 --out "$OUT.flipa" --err "$OUT.flipa.err" \
533 -- "$MUX" -A --sock "$SOCK49" > "$OUT.flipa.log" 2>&1 <<EOF &
534 expect \x1b[?1049h 15000
535 expect b-ready 25000
536 send ssh-add -l\n
537 expect $FPA 20000
538 send echo flip-now\n
539 expect $FPB 25000
540 send exit\n
541 waitexit 20000
542 EOF
543 FLIPAPID=$!
544 defer_kill "$FLIPAPID"
545 # B settles before its first keystroke and A does not need to: a shell still
546 # setting up its terminal can flush what was typed at it, and B's marker is
547 # the only line typed before any shell has spoken. Every later send waits on
548 # a marker, which is a barrier a settle could only approximate.
549 SSH_AUTH_SOCK="$AGENT49B" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
550 --out "$OUT.flipb" --err "$OUT.flipb.err" \
551 -- "$MUX" -A --sock "$SOCK49" > "$OUT.flipb.log" 2>&1 <<EOF
552 expect \x1b[?1049h 15000
553 settle 400 15000
554 send echo b-ready\n
555 expect flip-now 30000
556 send ssh-add -l\n
557 expect $FPB 20000
558 waitexit 20000
559 EOF
560 RCB=$?
561 wait "$FLIPAPID"
562 RCA=$?
563 FLIPAPID=""
564 set -e
565 # A first: B's script waits on A's marker, so a B that failed downstream of a
566 # broken A would otherwise be the only thing reported.
567 #
568 # Two fingerprints named, not one, because A waits for BOTH — its own while
569 # it is the one typing, then B's once B is. Which of the two went missing is
570 # the whole diagnosis, and only the verb line in the log below can say it.
571 [ "$RCA" -eq 0 ] || {
572 echo "e2e FAIL: agent-flip: client A exited $RCA. It wants its own $FPA"
573 echo " while it types, then B's $FPB once B does:"
574 cat "$OUT.flipa.log"; exit 1; }
575 # The load-bearing one. B typed last, so B's agent had to answer B's dial —
576 # a daemon that pinned the answerer to the first offerer, or to the
577 # lowest-numbered slot, prints $FPA here and this is where it is caught.
578 [ "$RCB" -eq 0 ] || {
579 echo "e2e FAIL: agent-flip: client B exited $RCB — it typed last and wanted"
580 echo " its own agent's $FPB back, not A's $FPA:"
581 cat "$OUT.flipb.log"; exit 1; }
582 # No grep over the captures beside these two rcs, deliberately. A's script
583 # matched $FPA, then its own marker, then $FPB, on a cursor that only moves
584 # forward — the flip is already asserted IN ORDER, in one capture. And a
585 # grep for either fingerprint in either capture would pass on both clients
586 # whatever routing did: they replicate one grid, so each capture holds both
587 # answers by the time the session ends.
588
589 # wait_pid_gone and not assert_stopped, because there is nothing left to
590 # stop: A typed `exit`, that was this daemon's only session, and a daemon
591 # leaves with its last one (server.zig pumpOnce). The positive leg above
592 # ends on assert_stopped only because its second scenario auto-started a
593 # fresh daemon on the same path to stop. What is asserted here is the same
594 # thing either way — the process this leg forked is gone.
595 wait_pid_gone "$D43PID" "agent-flip: the session ended and the daemon should follow"
596 D43PID=""
597 # Ended here rather than in the trap, for the reason the leg above gives:
598 # a green run should not leave two key-holding processes alive for the
599 # length of the teardown. The trap still names them.
600 softkill "$AGENT49APID" || true
601 AGENT49APID=""
602 softkill "$AGENT49BPID" || true
603 AGENT49BPID=""
604 ok "agent forwarding: the agent that answers is whoever typed last"
605
606 # The wall's own mouse claim needs a daemon of its own for the same reason 148 # The wall's own mouse claim needs a daemon of its own for the same reason
607 # every other leg does: the wall scenario above stops its daemon on the way 149 # every other leg does: the wall scenario above stops its daemon on the way
608 # out, so there is nothing left to reuse. 150 # out, so there is nothing left to reuse.
test/e2e_10_agent.sh
Old New
@@ -0,0 +1,461 @@
1 # shellcheck shell=sh
2 # e2e_10_agent.sh — sourced by test/e2e.sh after e2e_lib.sh. Scenarios run in
3 # the order they stand in; see the lib's header for what this file may
4 # assume and what it must register.
5 # Agent forwarding: one socket path, two daemons in turn. The positive leg
6 # ends by typing `exit`, which ends that daemon's only session and takes the
7 # daemon with it — so the refusal leg below starts its own on the freed path
8 # rather than inheriting one that is already gone. Both are $MUXD; neither
9 # may be auto-started (see the $PATH note at the top of this file).
10 #
11 # The agent lives beside the sockets rather than under $OUT: a unix path is
12 # capped at 107 bytes by sun_path, and this one is dialled from inside a
13 # session shell, so the room it has is the room every other socket in this
14 # file was given.
15 SOCK48="${TMPDIR:-/tmp}/muxd-e2e-agentfwd-$$.sock"
16 defer_sock "$SOCK48"
17 AGENT48="${TMPDIR:-/tmp}/mux-e2e-agent-$$.sock"
18 defer_rm "$AGENT48"
19 AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$"
20 defer_rm "$AGENT48KEY" "$AGENT48KEY.pub"
21 # The flip leg gets a daemon of its own, and not for isolation's sake: the
22 # one above is stopped by its own scenario's assert_stopped, so there is no
23 # daemon left to reuse. Two agents, two keys, two sockets — one agent behind
24 # both clients would print the same fingerprint whichever slot answered, and
25 # the leg would pass having proved nothing. Beside the sockets for the
26 # sun_path reason above; these are dialled from inside a session too.
27 SOCK49="${TMPDIR:-/tmp}/muxd-e2e-agentflip-$$.sock"
28 defer_sock "$SOCK49"
29 AGENT49A="${TMPDIR:-/tmp}/mux-e2e-agent2a-$$.sock"
30 defer_rm "$AGENT49A"
31 AGENT49B="${TMPDIR:-/tmp}/mux-e2e-agent2b-$$.sock"
32 defer_rm "$AGENT49B"
33 AGENT49AKEY="${TMPDIR:-/tmp}/mux-e2e-agent2akey-$$"
34 defer_rm "$AGENT49AKEY" "$AGENT49AKEY.pub"
35 AGENT49BKEY="${TMPDIR:-/tmp}/mux-e2e-agent2bkey-$$"
36 defer_rm "$AGENT49BKEY" "$AGENT49BKEY.pub"
37
38 # --- agent forwarding: a real key in the client's agent answers in the session
39 #
40 # Everything under this heading is a real program doing its real job: a real
41 # ssh-agent holding a real ed25519 key, a real `ssh-add -l` typed into a real
42 # session shell on a real pty. Nothing here stands in for anything, and that
43 # is the point — the daemon and the client both carry the agent protocol
44 # BLIND (server.zig's serviceAgentChan copies bytes it never parses), so a
45 # stand-in agent would be this suite grading the pump against a second guess
46 # at a format neither end reads. openssh either recognises what came back or
47 # it does not, and that answer is the only one worth having.
48 #
49 # The needle is a SHA256 fingerprint, and it is echo-proof by the M12 rule
50 # (tp2, argued at the OSC 52 leg above): the shell echoes what is typed, so
51 # a needle a keystroke could spell would pass on a client that forwarded
52 # nothing at all. The typed line is `ssh-add -l`, which cannot spell a
53 # fingerprint. What puts those 50 bytes on the terminal is the agent on this
54 # machine answering a request that left the session, crossed the daemon,
55 # reached the client, and came back.
56 start_daemon "$SOCK48" "$OUT.agt.d" "agent daemon never bound" --shell /bin/sh
57 D42PID=$DPID
58
59 # The agent is started AFTER the daemon and its path is never exported, so
60 # the daemon cannot have inherited it: the only SSH_AUTH_SOCK a session on
61 # this daemon can hold is one the daemon bound itself. That ordering is what
62 # makes the fingerprint below evidence of a forward rather than of a leak.
63 ssh-agent -a "$AGENT48" > "$OUT.agt.env" 2>&1
64 AGENT48PID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.agt.env")
65 defer_kill "$AGENT48PID"
66 [ -n "$AGENT48PID" ] || {
67 echo "e2e FAIL: agent: ssh-agent printed no pid for the trap to hold it by:"
68 cat "$OUT.agt.env"; exit 1; }
69 # -C, so `ssh-add -l` prints a short comment instead of this key's whole
70 # path: the answer has to fit one row of the terminal below, and a line that
71 # wrapped could split the fingerprint across two rows the capture never
72 # joins back up.
73 ssh-keygen -q -t ed25519 -N '' -C mux-e2e-agent -f "$AGENT48KEY"
74 SSH_AUTH_SOCK="$AGENT48" ssh-add "$AGENT48KEY" > /dev/null 2>&1
75 FP48=$(ssh-keygen -lf "$AGENT48KEY" | awk '{print $2}')
76 # An empty or malformed needle would reach ptyclient as a usage error rather
77 # than as a failed expectation, and the suite would report the wrong thing.
78 case "$FP48" in
79 SHA256:?*) ;;
80 *) echo "e2e FAIL: agent: ssh-keygen -lf gave no fingerprint to look for: [$FP48]"
81 exit 1;;
82 esac
83
84 # Unquoted heredoc, like the OSC 52 leg's and for the same reason: $FP48 has
85 # to interpolate. Nothing else in the script needs expanding — `\n` and
86 # `\x1b` are not escapes the shell touches.
87 set +e
88 SSH_AUTH_SOCK="$AGENT48" timeout 40 "$PTYCLIENT" --cols 100 --rows 30 \
89 --out "$OUT.agt" --err "$OUT.agt.err" \
90 -- "$MUX" -A --sock "$SOCK48" > "$OUT.agt.log" 2>&1 <<EOF
91 expect \x1b[?1049h 15000
92 settle 400 15000
93 send ssh-add -l\n
94 expect $FP48 15000
95 settle 400 15000
96 send exit\n
97 waitexit 10000
98 EOF
99 RC=$?
100 set -e
101 # Only the fixture's log, not the capture beside it: ptyclient prints the
102 # verb that failed and the last 200 bytes it saw, which is the answer. The
103 # capture is a screenful of escapes that would bury it.
104 [ "$RC" -eq 0 ] || {
105 echo "e2e FAIL: agent: the client's key never answered ssh-add -l in the"
106 echo " session (wanted $FP48):"
107 cat "$OUT.agt.log"; exit 1; }
108 ok "agent forwarding: ssh-add -l in the session lists the client's key"
109
110 # --- ...and `-A` with nothing behind it is refused before the attach ------
111 #
112 # The flag is a promise the client cannot keep with no agent running, and it
113 # used to be kept silently: the offer is a declaration rather than a
114 # capability, so every dial the session made was refused without a word and
115 # the user met it three layers down as `permission denied (publickey)` from
116 # a git remote. Refused at usage-error altitude now, exit 2, before the
117 # transport is touched.
118 #
119 # Both spellings of "no agent", because reading the variable would only
120 # catch the first: unset, and set to a path nothing answers on. The unit
121 # test covers the subtler third — an agent killed leaving its socket file
122 # behind, which stats fine and refuses the connect — since arranging a
123 # listener that dies mid-leg buys nothing here.
124 set +e
125 env -u SSH_AUTH_SOCK "$MUX" -A --sock "$SOCK48" > "$OUT.anoag" 2>&1
126 RC=$?
127 set -e
128 [ "$RC" -eq 2 ] || {
129 echo "e2e FAIL: agent: -A with no SSH_AUTH_SOCK exited $RC, want 2:"
130 cat "$OUT.anoag"; exit 1; }
131 grep -q "is not set" "$OUT.anoag" || {
132 echo "e2e FAIL: agent: -A with no SSH_AUTH_SOCK refused for another reason:"
133 cat "$OUT.anoag"; exit 1; }
134
135 set +e
136 SSH_AUTH_SOCK="$OUT.dead-agent.sock" "$MUX" -A --sock "$SOCK48" > "$OUT.anoag2" 2>&1
137 RC=$?
138 set -e
139 [ "$RC" -eq 2 ] || {
140 echo "e2e FAIL: agent: -A at a dead agent path exited $RC, want 2:"
141 cat "$OUT.anoag2"; exit 1; }
142 grep -q "no ssh-agent answering at" "$OUT.anoag2" || {
143 echo "e2e FAIL: agent: -A at a dead agent path refused for another reason:"
144 cat "$OUT.anoag2"; exit 1; }
145
146 # The control: the SAME command with the SAME live agent the leg above used
147 # gets PAST the preflight, so the two refusals are about the agent and not
148 # about the flag being rejected outright. Bounded and its exit ignored —
149 # past the preflight it goes on to attach for real, which without a
150 # terminal is neither a pass nor a failure, only slow.
151 set +e
152 SSH_AUTH_SOCK="$AGENT48" timeout 5 "$MUX" -A --sock "$SOCK48" > "$OUT.anoag3" 2>&1
153 set -e
154 # `if`, not `grep && {...}`: under `set -e` an AND-list ending in a failed
155 # grep takes the whole suite down, and here a failed grep is the PASS.
156 if grep -q "no ssh-agent" "$OUT.anoag3"; then
157 echo "e2e FAIL: agent: a REACHABLE agent was still refused by the preflight:"
158 cat "$OUT.anoag3"; exit 1
159 fi
160 ok "agent forwarding: -A with no agent is a usage error, not a silent no-op"
161
162 # --- ...and a session nobody offered an agent to refuses, fast -------------
163 #
164 # A fresh daemon on the freed path, and the spawn is load-bearing: the leg
165 # above took its daemon down with its last session, so a client dialling
166 # $SOCK48 now would AUTO-START one off $PATH instead — a daemon that need
167 # not be the one under test, and against a release with no agent code in it
168 # every assertion below passes having witnessed nothing.
169 #
170 # The sibling case — a session beside one that HAS an answerer — is the unit
171 # suite's; what this leg witnesses is the plainer shape, a live daemon
172 # holding a single session nobody has offered an agent to.
173 #
174 # SSH_AUTH_SOCK is still set inside that session — every session is born
175 # with one, whether or not anyone has offered — and it points at the daemon,
176 # which accepts the dial and closes it. That is the designed answer, not a
177 # gap: ssh reads a closed agent socket as "agent refused operation" and
178 # falls straight through to its other methods, where a connection accepted
179 # and left silent would make it wait out a timeout on every dial.
180 wait_pid_gone "$D42PID" "agent forwarding: the positive leg's daemon outlived its last session"
181 start_daemon "$SOCK48" "$OUT.agtn.d" "agent-refusal daemon never bound" --shell /bin/sh
182 D42PID=$DPID
183 AR0=$(date +%s%N)
184 set +e
185 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.agtn" --err "$OUT.agtn.err" \
186 -- "$MUX" --sock "$SOCK48" --session noagent > "$OUT.agtn.log" 2>&1 <<'EOF'
187 expect \x1b[?1049h 15000
188 settle 400 15000
189 send echo agtsock=${SSH_AUTH_SOCK:+present}\n
190 expect agtsock=present 15000
191 send ssh-add -l; echo agtrc=$?\n
192 expect agtrc= 15000
193 settle 400 15000
194 send exit\n
195 waitexit 10000
196 EOF
197 RC=$?
198 set -e
199 AR1=$(date +%s%N)
200 AMS=$(( (AR1 - AR0) / 1000000 ))
201 [ "$RC" -eq 0 ] || {
202 echo "e2e FAIL: agent-refusal: the leg exited $RC:"
203 cat "$OUT.agtn.log"; exit 1; }
204 # Quoted heredoc, unlike the leg above: `$?` has to reach the SESSION's
205 # shell as two characters, not be spent by this one.
206 #
207 # `expect agtsock=present` is the leg's non-vacuity pin, and unlike the
208 # needle below it IS an assertion: the typed line spells the parameter
209 # expansion, so only a shell that ran it can put the word `present` on its
210 # own line. Everything else here would pass just as well against a daemon
211 # that had never heard of agents — no SSH_AUTH_SOCK at all also means no
212 # key, no signature and a nonzero status. This verb is what separates
213 # "accepted the dial and closed it" from "nothing was ever offered".
214 #
215 # `expect agtrc=` is a wait and not an assertion — the shell echoes the line
216 # it was typed, so that needle matches before ssh-add has run. The settle
217 # after it is what waits for the answer; these two greps are the assertion,
218 # and both are echo-proof, because the typed line spells `agtrc=$?` and
219 # neither `agtrc=0` nor a digit can come from echoing it.
220 grep -q "agtrc=0" "$OUT.agtn" && {
221 echo "e2e FAIL: agent-refusal: ssh-add succeeded in a session nobody offered"
222 echo " an agent to — something answered for a key that is not there:"
223 cat -v "$OUT.agtn"; exit 1; }
224 grep -qE "agtrc=[1-9]" "$OUT.agtn" || {
225 echo "e2e FAIL: agent-refusal: ssh-add never reported a status at all — the"
226 echo " session did not run it, so its failure proves nothing:"
227 cat -v "$OUT.agtn"; exit 1; }
228 # Ceiling only, and the floor's absence is the claim: a refusal is a close on
229 # an already-accepted connection and should cost about nothing, so there is
230 # no lower bound worth defending. What the ceiling owns is the band this
231 # leg's own deadlines cannot see. A refusal that HUNG is caught by the
232 # waitexit above and reported as an exit code; a refusal that came back
233 # after ten seconds of retrying is not — `expect` matched the echo long
234 # before, and the settle would sit through the whole retry and still pass.
235 # This bound is what makes that band a failure.
236 #
237 # 10000 for the reason the key-mismatch leg above picked the same number: it
238 # separates "refused" from "sat in a loop" and is not asked to do anything
239 # finer. The whole leg — attach, two 400ms settles, the agtsock round trip,
240 # a shell's startup and the refusal itself — measured 816-837ms across the
241 # grading runs against the daemon started above, so a tighter ceiling would
242 # catch nothing this one misses while making a check that is nobody's
243 # bottleneck the suite's most load-sensitive one.
244 [ "$AMS" -lt 10000 ] || {
245 echo "e2e FAIL: agent-refusal: the leg took ${AMS}ms — a dial with nobody to"
246 echo " answer it must be refused, not retried"
247 exit 1; }
248
249 # --- ...and a nested `-A` inside such a session is refused, not passed -----
250 #
251 # The preflight's whole job is to refuse a client that cannot answer a
252 # challenge. Inside a session `SSH_AUTH_SOCK` names the daemon, which
253 # accepts every dial and only afterwards looks for an offerer to route it
254 # to — so the connect the preflight used to do succeeded there, and the
255 # nested client attached as an offerer that can answer nothing. Worse, it
256 # out-ranks a working `-A` client, because the answerer is the latest-active
257 # OFFERER and offering is a declaration.
258 #
259 # `--session noagent` is the session this shell is already in, deliberately:
260 # the preflight runs before any transport work, so it is what must answer
261 # here, and the self-attach refusal standing behind it means a regression
262 # fails this leg instead of spawning a nested attach into the pty. Which of
263 # the two spoke is what the message grep decides; the exit code alone
264 # cannot, since both refusals use 2.
265 #
266 # Unquoted heredoc, unlike the leg above: $MUX and $SOCK48 have to be
267 # expanded by THIS shell, so `$?` is escaped to reach the session's.
268 set +e
269 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.agtnest" --err "$OUT.agtnest.err" \
270 -- "$MUX" --sock "$SOCK48" --session noagent > "$OUT.agtnest.log" 2>&1 <<EOF
271 expect \x1b[?1049h 15000
272 settle 400 15000
273 send $MUX -A --sock $SOCK48 --session noagent; echo nest=\$?\n
274 settle 1500 15000
275 send exit\n
276 waitexit 10000
277 EOF
278 RC=$?
279 set -e
280 [ "$RC" -eq 0 ] || {
281 echo "e2e FAIL: agent-nested: the leg exited $RC:"
282 cat "$OUT.agtnest.log"; exit 1; }
283 # Both needles are echo-proof: the typed line spells `nest=$?` and says
284 # nothing about ssh-agents, so neither `nest=2` nor the message below can
285 # come from the shell echoing what it was sent.
286 grep -q "nest=2" "$OUT.agtnest" || {
287 echo "e2e FAIL: agent-nested: a nested \`mux -A\` did not exit 2 — the"
288 echo " preflight let through a client with no agent behind it:"
289 cat -v "$OUT.agtnest"; exit 1; }
290 grep -q "no ssh-agent answering at" "$OUT.agtnest" || {
291 echo "e2e FAIL: agent-nested: the nested \`mux -A\` was refused, but not by"
292 echo " the agent preflight — something else answered first:"
293 cat -v "$OUT.agtnest"; exit 1; }
294 ok "agent forwarding: a nested -A with nobody offering is refused"
295
296 assert_stopped "$SOCK48" "$D42PID" "agent forwarding" "$OUT.agtstop"
297 D42PID=""
298 # The agent has no more work; ended here rather than in the trap so a green
299 # run does not leave a key-holding process alive for the length of the
300 # teardown. The trap still names it, for every run that does not reach here.
301 softkill "$AGENT48PID" || true
302 AGENT48PID=""
303 # The measurement is printed on a PASSING run too, not just quoted in the
304 # failure above: the band this leg's ceiling was chosen against lives in
305 # docs/decisions.md, and a number nobody can see on a green run is a number
306 # that quietly stops being true.
307 ok "agent forwarding: no offerer means a fast refusal, not a hang (${AMS}ms)"
308
309 # --- ...and the agent that answers is whoever typed last -------------------
310 #
311 # Two clients on ONE session, each offering its OWN ssh-agent holding its
312 # OWN key. `ssh-add -l` typed at one keyboard and then at the other has to
313 # come back with two DIFFERENT fingerprints — that is the spec's "latest
314 # wins, applied to keys", and it is a claim only two agents can make.
315 start_daemon "$SOCK49" "$OUT.flip.d" "agent-flip daemon never bound" --shell /bin/sh
316 D43PID=$DPID
317
318 # Both agents after the daemon, for the ordering reason the leg above gives:
319 # a path the daemon cannot have inherited is what makes a fingerprint
320 # evidence of a forward rather than of a leak.
321 ssh-agent -a "$AGENT49A" > "$OUT.flipa.env" 2>&1
322 AGENT49APID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.flipa.env")
323 defer_kill "$AGENT49APID"
324 ssh-agent -a "$AGENT49B" > "$OUT.flipb.env" 2>&1
325 AGENT49BPID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.flipb.env")
326 defer_kill "$AGENT49BPID"
327 [ -n "$AGENT49APID" ] && [ -n "$AGENT49BPID" ] || {
328 echo "e2e FAIL: agent-flip: an ssh-agent printed no pid for the trap to hold"
329 cat "$OUT.flipa.env" "$OUT.flipb.env"; exit 1; }
330 # -C for the wrap reason the leg above gives, and two different comments so
331 # a capture holding the wrong answer says out loud whose it is.
332 ssh-keygen -q -t ed25519 -N '' -C mux-e2e-flip-a -f "$AGENT49AKEY"
333 ssh-keygen -q -t ed25519 -N '' -C mux-e2e-flip-b -f "$AGENT49BKEY"
334 SSH_AUTH_SOCK="$AGENT49A" ssh-add "$AGENT49AKEY" > /dev/null 2>&1
335 SSH_AUTH_SOCK="$AGENT49B" ssh-add "$AGENT49BKEY" > /dev/null 2>&1
336 FPA=$(ssh-keygen -lf "$AGENT49AKEY" | awk '{print $2}')
337 FPB=$(ssh-keygen -lf "$AGENT49BKEY" | awk '{print $2}')
338 for _fp in "$FPA" "$FPB"; do
339 case "$_fp" in
340 SHA256:?*) ;;
341 *) echo "e2e FAIL: agent-flip: ssh-keygen -lf gave no fingerprint to look for: [$_fp]"
342 exit 1;;
343 esac
344 done
345 # Never expected, and asserted anyway, because it is the one failure this
346 # leg cannot see: two keys that hashed alike would satisfy both expects
347 # below without anything having flipped.
348 [ "$FPA" != "$FPB" ] || {
349 echo "e2e FAIL: agent-flip: both agents hold the same fingerprint [$FPA] —"
350 echo " no expect below could tell one answerer from the other"
351 exit 1; }
352
353 # The sequencing, and why it needs no sleeps.
354 #
355 # `agentAnswerer` (server.zig) ranks the session's offerers by
356 # ClientSlot.activity and reads that ranking ONCE, when ssh-add's dial is
357 # accepted. Activity is bumped by three verbs only — attach, input, resize
358 # (bumpActivity) — and the input bump happens as the frame is handled,
359 # strictly before those bytes reach the pty. So a client's own query is
360 # always the last bump before its own dial, PROVIDED nobody else attaches or
361 # types in between. That proviso is the entire choreography:
362 #
363 # the second attach is a bump too, so A cannot simply query first and hope
364 # B has not arrived yet. Instead B announces itself by TYPING and A waits
365 # for that line to reach its own screen. A's query is then provably after
366 # B's attach AND after B's only keystroke, because A did not type until
367 # B's bytes had crossed the daemon, the pty and back;
368 #
369 # the baton back is the same trick mirrored: A types a marker after its
370 # answer, B is watching the same grid and types only once it lands, so B's
371 # bump is provably after A's and B's dial is routed to B.
372 #
373 # Which also makes the leg independent of which client attaches first — the
374 # order is fixed by typed markers, not by process start.
375 #
376 # Both markers are matched on the shell's ECHO, and that is sound here in a
377 # way it would not be for an assertion: the M12 echo-proof rule guards
378 # needles that claim something crossed the WIRE, and a marker claims only
379 # that a keystroke was handled — which is precisely what an echo witnesses,
380 # and witnesses earlier than any command output could.
381 #
382 # The fingerprints are not markers and are echo-proof by that same rule:
383 # `ssh-add -l` cannot spell one.
384 #
385 # Unquoted heredocs, like the positive leg's: $FPA and $FPB have to
386 # interpolate, and nothing else in either script needs expanding.
387 set +e
388 SSH_AUTH_SOCK="$AGENT49A" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
389 --out "$OUT.flipa" --err "$OUT.flipa.err" \
390 -- "$MUX" -A --sock "$SOCK49" > "$OUT.flipa.log" 2>&1 <<EOF &
391 expect \x1b[?1049h 15000
392 expect b-ready 25000
393 send ssh-add -l\n
394 expect $FPA 20000
395 send echo flip-now\n
396 expect $FPB 25000
397 send exit\n
398 waitexit 20000
399 EOF
400 FLIPAPID=$!
401 defer_kill "$FLIPAPID"
402 # B settles before its first keystroke and A does not need to: a shell still
403 # setting up its terminal can flush what was typed at it, and B's marker is
404 # the only line typed before any shell has spoken. Every later send waits on
405 # a marker, which is a barrier a settle could only approximate.
406 SSH_AUTH_SOCK="$AGENT49B" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
407 --out "$OUT.flipb" --err "$OUT.flipb.err" \
408 -- "$MUX" -A --sock "$SOCK49" > "$OUT.flipb.log" 2>&1 <<EOF
409 expect \x1b[?1049h 15000
410 settle 400 15000
411 send echo b-ready\n
412 expect flip-now 30000
413 send ssh-add -l\n
414 expect $FPB 20000
415 waitexit 20000
416 EOF
417 RCB=$?
418 wait "$FLIPAPID"
419 RCA=$?
420 FLIPAPID=""
421 set -e
422 # A first: B's script waits on A's marker, so a B that failed downstream of a
423 # broken A would otherwise be the only thing reported.
424 #
425 # Two fingerprints named, not one, because A waits for BOTH — its own while
426 # it is the one typing, then B's once B is. Which of the two went missing is
427 # the whole diagnosis, and only the verb line in the log below can say it.
428 [ "$RCA" -eq 0 ] || {
429 echo "e2e FAIL: agent-flip: client A exited $RCA. It wants its own $FPA"
430 echo " while it types, then B's $FPB once B does:"
431 cat "$OUT.flipa.log"; exit 1; }
432 # The load-bearing one. B typed last, so B's agent had to answer B's dial —
433 # a daemon that pinned the answerer to the first offerer, or to the
434 # lowest-numbered slot, prints $FPA here and this is where it is caught.
435 [ "$RCB" -eq 0 ] || {
436 echo "e2e FAIL: agent-flip: client B exited $RCB — it typed last and wanted"
437 echo " its own agent's $FPB back, not A's $FPA:"
438 cat "$OUT.flipb.log"; exit 1; }
439 # No grep over the captures beside these two rcs, deliberately. A's script
440 # matched $FPA, then its own marker, then $FPB, on a cursor that only moves
441 # forward — the flip is already asserted IN ORDER, in one capture. And a
442 # grep for either fingerprint in either capture would pass on both clients
443 # whatever routing did: they replicate one grid, so each capture holds both
444 # answers by the time the session ends.
445
446 # wait_pid_gone and not assert_stopped, because there is nothing left to
447 # stop: A typed `exit`, that was this daemon's only session, and a daemon
448 # leaves with its last one (server.zig pumpOnce). The positive leg above
449 # ends on assert_stopped only because its second scenario auto-started a
450 # fresh daemon on the same path to stop. What is asserted here is the same
451 # thing either way — the process this leg forked is gone.
452 wait_pid_gone "$D43PID" "agent-flip: the session ended and the daemon should follow"
453 D43PID=""
454 # Ended here rather than in the trap, for the reason the leg above gives:
455 # a green run should not leave two key-holding processes alive for the
456 # length of the teardown. The trap still names them.
457 softkill "$AGENT49APID" || true
458 AGENT49APID=""
459 softkill "$AGENT49BPID" || true
460 AGENT49BPID=""
461 ok "agent forwarding: the agent that answers is whoever typed last"