fbfd75df
test: 10_agent moves to a file of its own
a73x 2026-08-26 19:12
Commit message
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" | ||