fix(ssh-controlmaster): prevent session_start from hanging on unreachable host

pi awaits session_start before the agent loop accepts input, so a blocking
ssh call there presents as "TUI is up but prompts are silently ignored".
The remote pwd probe and ControlMaster start had no ConnectTimeout, no
BatchMode, and no wall-clock cap, so an unreachable host (classic case:
`pi-dev --ssh <lan-host>` runs pi *inside* the devbox container, which has
no route to LAN hosts without a ProxyJump) blocked startup on the OS TCP
timeout (~75s+) while silently swallowing keystrokes.

Hardening:
- CONNECT_OPTS (ConnectTimeout=8 + ServerAlive keepalives) on every ssh
  invocation: pwd probe, master start (both key and password paths),
  sshExec, and bash exec.
- run() gains a timeoutMs option (kills child + rejects); pwd probe and both
  master-start paths bounded by STARTUP_TIMEOUT_MS (15s).
- BATCH_OPTS (BatchMode=yes) on key-auth calls so ssh never waits on a
  /dev/tty password/passphrase/host-key prompt behind the TUI; omitted under
  --ssh-ask-pass so the SSH_ASKPASS path still works. askPass flag is now
  read before the probe to pick the right opts.
- Probe failure sets an "unreachable" status + error toast and returns,
  instead of throwing an unhandled rejection from the handler.

Docs: README "How it works" + AGENTS.md technical note updated.
This commit is contained in:
2026-06-20 22:16:48 +02:00
parent 6f7dca06e8
commit aaa7d906df
3 changed files with 98 additions and 21 deletions
+19
View File
@@ -115,6 +115,25 @@ on a remote machine via SSH when `--ssh user@host` is passed.
so a read-only system `ControlPath` (which a plain `ssh … pwd` would try, and
fail, to bind a master socket into) cannot make the initial probe exit 255.
- **Hang-proofing (no ssh call can block the UI).**
`session_start` is `await`ed by pi before the agent loop accepts input, so a
blocking ssh call there manifests as *"the TUI is up but my prompts are
silently ignored."* Guards: a shared `CONNECT_OPTS` (`ConnectTimeout=8` +
`ServerAliveInterval=5`/`ServerAliveCountMax=3`) is appended to **every** ssh
invocation (pwd probe, master start, `sshExec`, bash exec); the `pwd` probe
uses `run(..., { timeoutMs: STARTUP_TIMEOUT_MS })` (15 s) and both master-start
paths kill the child + reject on the same wall-clock cap. Key-auth calls also
carry `BATCH_OPTS` (`BatchMode=yes`) so ssh refuses any `/dev/tty`
password/passphrase/host-key prompt rather than waiting on it invisibly;
`--ssh-ask-pass` omits `BatchMode` (it would disable `SSH_ASKPASS`). The
`askPass` flag is read **before** the probe so the probe picks the right opts.
On probe failure the handler sets an `✗ unreachable` status + error toast and
`return`s (instead of throwing an unhandled rejection). Common trigger: a LAN
host reachable from the macOS host but **not** from inside the pi-devbox
container (`pi-dev` = `docker compose … exec devbox pi`); the container has no
route without a `ProxyJump host` entry, so `pi --ssh <lan-host>` from the
container now fails fast instead of stalling on the OS TCP timeout.
- **Password auth via `--ssh-ask-pass`.**
When the flag is set, `ctx.ui.input()` prompts for a password before
connecting. The password is passed to SSH via `SSH_ASKPASS`: a temp script at