feat(session): surface exec exit-status / exit-signal via SshSession.exitInfo - #232
Conversation
…p probe (#58) Phase 3 hit an upstream wall worth documenting: sshlib runs exec but drops the RFC 4254 §6.10 exit-status/exit-signal reports, so ExecResult.exitStatus cannot be produced honestly — and run_command automations branch on exit codes. Upstreamed as connectbot/cbssh#232 (SshSession.exitInfo: Deferred<SessionExit?>, flow + OpenSSH-9.9p2 integration tests, 1170 upstream tests green). Haven side, landable today: ExecContractTest — the engine-agnostic exec contract (stdout, stderr, exit codes, 1 MiB output, timeout shape) against MINA with an in-process scripted CommandFactory, run by JschExecContractTest as the behaviour pin; plus a spike GAP probe asserting the released sshlib has no exit API — it fails when a release carries #232, which is the signal to add SshlibExecContractTest and the engine-aware exec routing.
|
Some context on where this fits, @kruton, for whenever you next have review bandwidth (CI is green across JDK 17/21/25 + CodeQL). This closes the last gap for adopting sshlib as a second SSH engine in Haven — the migration you kindly kicked off back in GlassHaven/Haven#58 when you reached out about ssh-proto. Since then the exec (#95), subsystem (#98), SFTP (#112) and FIDO2/SK (#146) work all landed upstream, and Haven now has the per-engine building blocks built and tested against real servers:
The one thing still missing is a truthful command exit status. Haven runs Happy to reshape the API if you'd prefer something other than an exposed |
478277a to
0c4163e
Compare
…exitInfo The kaitai schemas already parse the RFC 4254 §6.10 exit-status and exit-signal channel requests, but the connection's packet loop dropped them after logging, so exec consumers had no way to learn a command's exit code. Adds a sealed SessionExit (Status / Signal) and a Deferred exitInfo on SshSession, completed from the packet loop when the server reports termination and resolved to null when the channel closes without a report (servers are not required to send one). First report wins per the RFC. Tests: four FakeSshServer flow tests (status, signal, close-without- report resolves null, first-report-wins) plus two Testcontainers integration tests against OpenSSH 9.9p2 (sh -c 'exit 42' and a KILL-terminated command). Metalava signature updated; metalavaCheckCompatibility passes.
0c4163e to
d961d41
Compare
…ult thresholds sshlib 0.4.0 carries the fix for connectbot/cbssh#231, the client-initiated rekey breakage Haven's phase-2 capability spike found: a byte-limit rekey mid-transfer killed the SFTP channel, and an interval rekey wedged an idle session outright. Upstream root-caused it to strict-KEX + ChaCha20/Poly1305 packet numbering and added a regression test. All three GAP probes flipped on the bump, which is how they were built to report: - byte-limit rekey during in-flight SFTP writes -> now roundtrips (2 MiB across a 256 KiB limit, ~8 rekeys); - interval rekey on an idle session -> next SFTP op completes instead of hanging; - SshSession.exitInfo present, so connectbot/cbssh#232 (Haven's exec exit-status contribution) is in this release too. So SshlibSftpConnector no longer pushes rekeyIntervalMs/rekeyBytesLimit to Long.MAX_VALUE/2 — that mitigation meant session keys never rotated. Defaults (1 GiB / 1 h) are back. The three probes are rewritten as PASS assertions that exercise real rekeys, so a regression fails the suite. Also migrates the spike's AgentProvider stub: 0.4.0 wraps getIdentities and signData in AgentResult (breaking change; Haven has no production implementation). core:ssh suite: 384 tests green.
Opt-in only, per profile, via the existing 'HavenSshEngine sshlib' sshOptions directive. JSch stays the default for everything that has not opted in — this is deliberately NOT the factory flip. SshConnectionFactory's SSHLIB branch returned a JSch stand-in because exec was missing; sshlib 0.4.0 shipped SshSession.exitInfo (our connectbot/cbssh#232), so the last block landed: - SshlibExec: one-shot exec matching the contract the JSch leg pins — concurrent stdout/stderr drain, real RFC 4254 6.10 exit status, and the documented timeout shape. A signal-terminated command reports -1 rather than inventing a 128+signum the server never sent. - SshlibConnection: shell + RemoteCommand exec, one-shot exec, SFTP and L/R/dynamic forwards over ONE sshlib transport. Honest about its limits: connect() refuses jump/proxy dials, FIDO2, OpenSSH certificates and MFA chains through the existing unsupportedReason gate instead of quietly doing something else. A jump host reaches us as a HavenProxy, so that gate also stops a jump profile dialing direct. Host keys follow the engine-neutral contract — the key is captured and returned from connect() for Haven's normal TOFU prompt, the same accept-then-verify order JSch uses. The dedicated SFTP dial stays fail-closed (trust was already established by the interactive connect before it); a whole connection has no such predecessor and would otherwise refuse every new host. Two ownership bugs found while assembling: SshlibSftpSession.close() and SshlibShell's disconnect both dropped the whole client. Correct for a dedicated dial, but on a shared connection closing a file browser or one terminal tab would have killed the profile's other channels and forwards. Both now take an explicit ownsClient flag. SshlibShell also reports the real exit status now. Gates: core:ssh 390 tests green (exec contract 5/5 on BOTH engines; shell, SFTP and port-forward contracts already dual-engine) and a full :app:assembleArm64Release, per the #58 lesson that cross-module breakage only surfaces in the release build. SshConnectionFactoryTest flipped from pinning 'both engines build a JSch connection' to pinning per-engine types plus a new case asserting JSch remains the default, so the experimental engine cannot become default by accident. Also tracks SshlibShellChannelContractTest, the phase-5 shell parity gate that was sitting untracked and so never ran in CI.
What
Adds
SshSession.exitInfo: Deferred<SessionExit?>— the RFC 4254 §6.10exit-status/exit-signalreports, which the packet loop previously parsed (the kaitai types were already wired intoSshMsgChannelRequest) but dropped after logging. Without this, an exec consumer can read stdout/stderr to EOF but never learn how the command terminated.SessionExitsealed interface:Status(code)/Signal(signalName, coreDumped, errorMessage)nullwhen the channel closes without a report (servers aren't required to send one), so waiters are never left suspendedmetalavaCheckCompatibilitypassesWhy
Haven is adopting sshlib as its second SSH engine (context in GlassHaven/Haven#58); the next slice is remote command execution, where automations branch on exit codes. This was the one missing piece for exec parity — happy to adjust the API shape (e.g. a suspend accessor instead of the exposed
Deferred) if you'd prefer.Tests
FakeSshServerflow tests: exit-status completesexitInfo; exit-signal completes it; close-without-report resolves null; first report wins over a later onesh -c 'exit 42'→Status(42); a KILL-terminated command →Signal("KILL", …):sshlib:testsuite: 1170 tests, 0 failures (unit + integration, Docker available)