fix(msb): detect stale ssh-agent --vsock route after a host reboot - #416
Conversation
After a host reboot, the create-time --vsock route pins the host ssh-agent socket path captured at create time; msb start / re-attach resume the sandbox with that persisted route, but the host ssh-agent now listens on a NEW socket path, so the route's host endpoint is dead. The in-guest socat bridge and the SSH_AUTH_SOCK marker are (re)placed faithfully (the #392 running-reattach fix), yet the bridge connects to a dead host endpoint and git signing fails silently. The --vsock route is create-time only and cannot be refreshed on a running sandbox, so acq cannot transparently heal this. Add a liveness probe (_acq_msb_warn_if_agent_unreachable) that runs after the bridge is (re)started: it runs 'ssh-add -l' over the guest sock and, when the agent is unreachable, prints an actionable warning naming the host-reboot cause and the 'acq rm' + re-run remedy. The probe classifies on the ssh-add MESSAGE, not the exit code alone: when the bridge's listener socket exists but its vsock backend is dead, ssh-add exits 1 with 'communication with agent failed' -- the SAME exit code as a healthy-but- empty agent. Exit 0 and exit 1 with 'no identities' stay quiet; anything else on failure warns. The warn-return is '|| true'-guarded so it can never abort a verb under 'set -euo pipefail'. A short bounded retry (3x0.3s) absorbs a fresh-create race. The probe skips silently when ssh-add is absent in the guest. Tests (bats 10c19/10c19b/10c20/10c20b/10c21) cover the reboot dead-bridge case, a missing socket, a healthy agent with/without keys, the no-abort-under-set-e guarantee, and the ssh-add-absent skip. Documented in ADR-0021 and docs/KNOWN_FAILURE_MODES.md section 34. Refs: #413 Co-authored-by: OpenCode [claude-opus-4] <bret.mogilefsky@gsa.gov>
The bash 3.2 compat gate's setup step fetched the bash-3.2.57 tarball and signature from a single host (ftp.gnu.org), which intermittently refuses connections (curl 28 'Failed to connect') and red-flags healthy PRs before any test runs. Try ftpmirror.gnu.org (GNU's redirect to a live mirror) first and fall back to ftp.gnu.org, with --connect-timeout and --retry-connrefused. Integrity is unchanged: the detached OpenPGP signature (Chet Ramey's key by full fingerprint) still authenticates the bytes regardless of which mirror served them. Scoped to the tarball leg (the observed failure); keyserver vendoring remains tracked in #395. Refs: #395
8268005 to
857b5c2
Compare
wz-gsa
left a comment
There was a problem hiding this comment.
Adversarial review — LGTM, verified the classification logic and injection surface directly
Root cause is correctly diagnosed and distinct from #392 (which fixed the env-var injection on reattach; this is one layer deeper — the --vsock route itself is create-time-only and never re-derived, so a host reboot that rotates the host ssh-agent's socket leaves a bridge that faithfully restarts and connects to a dead endpoint).
Verified the exit-code/message classification against the real binary, not just the PR's claim:
$ strings $(command -v ssh-add) | grep -i identities
The agent has no identities.
The *"no identities"* substring check matches this exactly, and I found no other "empty keyring" message variant on this system that could slip past it — the classification is complete, not just plausible.
Verified the injection surface is clean. $name/$_sock are interpolated unquoted into the sh -c body, which would normally be a flag — but both are acq-internal constants at this call site: $_sock traces back through _acq_msb_ssh_auth_sock_for/ACQ_MSB_SSH_AGENT_GUEST_SOCK, never from untrusted external input in this path. The comment correctly identifies this.
The 10c20b regression is the right kind of test — it doesn't just assert the || true guard exists, it empirically proves the bridge-starter still returns 0 under set -euo pipefail when the probe warns, which is the actual property that matters (a silent-failure fix that itself crashes the verb would be worse than the bug it fixes).
The bash32-compat.yml mirror-fallback is a disclosed second commit, not a silent rider like the ones flagged on #391/#410 — its own commit message, and it's the same fix class I'd suggested for the signing-key keyserver flake (#332/#395), applied here to the tarball fetch itself: try ftpmirror.gnu.org first, fall back to the canonical host, with the correct note that integrity doesn't depend on which mirror served the bytes (the OpenPGP signature check is unchanged and still authoritative).
Test coverage is thorough and matches the stated scenarios 1:1: reboot-dead-bridge warns (10c19), missing-socket warns (10c19b), healthy-with/without-keys stays quiet (10c20), no-abort-under-set-e (10c20b), ssh-add-absent skips silently (10c21).
Honest about its own limit — the PR body correctly flags that the probe's behavior against a genuinely stale real vsock route hasn't been exercised on a live KVM/HVF host (can't run in CI or inside a sandbox); the offline suite covers the classification logic against faithful stubs. That's the right scope for what can be verified without hardware access, and it's stated rather than implied.
All 5 CI checks green (380 passed offline). Approve.
AI-assisted (OpenCode).
Fixes #413.
Context
On the msb backend, after a host reboot (especially an unclean one that did not stop the sandbox first), resuming the sandbox and re-attaching leaves the guest unable to reach the host ssh-agent, so git signing fails — even though
SSH_AUTH_SOCKis set on both host and guest, the host agent holds the key, and the in-guestsocatbridge is running.This is a distinct failure from #392. #392 fixed the
SSH_AUTH_SOCKenv-var injection on running-reattach (restart the bridge + write the/var/lib/acq/ssh-auth-sockmarker) — that works. This is one layer deeper: the--vsock HOST_PATH:3552route is emitted only at create time and pinsHOST_PATHto the host'sSSH_AUTH_SOCKpath captured then; it is persisted and never re-derived onmsb start/ re-attach. A reboot restarts the host ssh-agent under a new socket path, so the persisted route's host endpoint is dead. The bridge faithfully restarts and connects to that dead endpoint (socat … VSOCK-CONNECT→ "Connection reset by peer"), leaving a silent dead bridge behind a present marker.Change
The
--vsockroute is create-time only and cannot be refreshed on a running sandbox, so acq cannot transparently heal this — the honest remedy is a recreate. Instead of failing silently (repo no-silent-failure rule), add a liveness probe in_acq_msb_start_ssh_agent_bridge(_acq_msb_warn_if_agent_unreachable) that runs after the bridge is (re)started and, when the forwarded agent is unreachable, prints an actionable warning naming the host-reboot cause and theacq rm+ re-run remedy.Key correctness detail (surfaced in review): the probe classifies on the
ssh-add -lmessage, not the exit code alone. When the socat listener socket exists but its vsock backend is dead,ssh-addconnects and then the agent protocol fails — it exits 1 withcommunication with agent failed, the same exit code as a healthy-but-empty agent (The agent has no identities.). Exit 2 is only a missing socket, which is not this scenario. So:Other robustness fixes from review:
|| true-guarded at the call site so it can never abort a verb underset -euo pipefail(the bridge starter is the last statement ofacq_backend_start, called bare bystart/restart). Regression test 10c20b asserts this.sleep 1, absorbing a fresh-create race without taxing the common already-running reattach.ssh-addis absent in the guest.No auto-refresh of the route is attempted; that is left as future work pending an msb capability to update a
--vsockroute in place (noted in the ADR).Files
acq.backends/msb.sh—_acq_msb_warn_if_agent_unreachable+ call site (guarded).scripts/test-acq-lib.sh— faithful ssh-add stub knobs (STUB_AGENT_UNREACHABLE= exit 1 + "communication…";STUB_AGENT_NO_SOCKET= exit 2;STUB_AGENT_NO_KEYS= exit 1 + "no identities").test/bats/115-ssh-agent-forward.bats— 10c19 (reboot dead-bridge warns), 10c19b (missing socket warns), 10c20 (healthy with/without keys quiet), 10c20b (no abort underset -e), 10c21 (ssh-add-absent skip).docs/adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md— new "Stale route after a host reboot (detect-and-report)" section.docs/KNOWN_FAILURE_MODES.md— §34 new variant with theVSOCK-CONNECT"Connection reset by peer" fingerprint and the recreate remedy.Verification
shellcheck --severity=warning acq.backends/msb.sh scripts/test-acq-lib.shscripts/test-acq-batsgitleaks protect --stagedset -eabort) — both fixed & re-verifiedReal ssh-add exit-code contract was confirmed live: dead-socket-backend → exit 1 "communication with agent failed"; empty keyring → exit 1 "no identities"; missing socket → exit 2.
Rollback
Revert the commit. The change is additive (a new helper + one guarded call site in the bridge starter); reverting restores the prior behavior (silent dead bridge after reboot). No config/data migration.
Security impact
Touches the ssh-agent forwarding path (ADR-0021). No new external service or data-classification change. The probe is read-only (
ssh-add -l), consent/opt-in unchanged, and never widens the forward. It cannot forward an agent into a sandbox that wasn't created for one.Live-verify still needed
The probe's behavior against a real rebooted msb guest (a genuinely stale route) has not been exercised end-to-end on a KVM/HVF host (cannot run in CI / inside a sandbox). Suggest verifying on the next sandbox-capable-host pass; the offline suite covers the classification logic against faithful ssh-add stubs.