Skip to content

feat(session): surface exec exit-status / exit-signal via SshSession.exitInfo - #232

Merged
kruton merged 1 commit into
connectbot:mainfrom
GlassOnTin:feat/exec-exit-status
Jul 23, 2026
Merged

feat(session): surface exec exit-status / exit-signal via SshSession.exitInfo#232
kruton merged 1 commit into
connectbot:mainfrom
GlassOnTin:feat/exec-exit-status

Conversation

@GlassOnTin

Copy link
Copy Markdown
Contributor

What

Adds SshSession.exitInfo: Deferred<SessionExit?> — the RFC 4254 §6.10 exit-status / exit-signal reports, which the packet loop previously parsed (the kaitai types were already wired into SshMsgChannelRequest) but dropped after logging. Without this, an exec consumer can read stdout/stderr to EOF but never learn how the command terminated.

  • SessionExit sealed interface: Status(code) / Signal(signalName, coreDumped, errorMessage)
  • Completed from the connection's channel-request dispatch for session channels; first report wins per the RFC
  • Resolves to null when the channel closes without a report (servers aren't required to send one), so waiters are never left suspended
  • Metalava signature updated; metalavaCheckCompatibility passes

Why

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

  • 4 FakeSshServer flow tests: exit-status completes exitInfo; exit-signal completes it; close-without-report resolves null; first report wins over a later one
  • 2 Testcontainers integration tests against OpenSSH 9.9p2: sh -c 'exit 42'Status(42); a KILL-terminated command → Signal("KILL", …)
  • Full :sshlib:test suite: 1170 tests, 0 failures (unit + integration, Docker available)

GlassOnTin added a commit to GlassHaven/Haven that referenced this pull request Jul 22, 2026
…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.
@GlassOnTin

Copy link
Copy Markdown
Contributor Author

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:

  • SFTP — shipped and device-verified on Haven's daily driver.
  • Shell / PTY — done, contract-tested against JSch byte-for-byte (banner-first-bytes, echo, resize, clean-exit).
  • Port forwarding (local / remote / dynamic SOCKS5) — done, contract-tested.

The one thing still missing is a truthful command exit status. Haven runs execCommand to detect tmux/zellij/screen sessions and to bootstrap Mosh/Eternal-Terminal, and those flows branch on the exit code — so the library dropping the RFC 4254 §6.10 exit-status/exit-signal report (the kaitai types already parse it; the packet loop just discards it after a debug log) means the exit code can't be surfaced honestly. Rather than fabricate a value, Haven has that whole path gated behind this PR: once it lands in a release, a sshlib-toggled Haven profile can run its terminal, tunnels and file transfer entirely over sshlib instead of JSch.

Happy to reshape the API if you'd prefer something other than an exposed Deferred<SessionExit?> — e.g. a suspend fun awaitExit(): SessionExit? accessor, or folding it into the existing close/lifecycle signalling. Whatever fits the library best; the wire parsing and tests are the substance.

Comment thread sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt Outdated
Comment thread sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt Outdated
Comment thread sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt Outdated
@kruton
kruton force-pushed the feat/exec-exit-status branch from 478277a to 0c4163e Compare July 23, 2026 15:01
…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.
@kruton
kruton force-pushed the feat/exec-exit-status branch from 0c4163e to d961d41 Compare July 23, 2026 15:02
@kruton
kruton enabled auto-merge (rebase) July 23, 2026 15:04
@kruton
kruton merged commit 32aecf1 into connectbot:main Jul 23, 2026
11 checks passed
GlassOnTin added a commit to GlassHaven/Haven that referenced this pull request Jul 23, 2026
…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.
GlassOnTin added a commit to GlassHaven/Haven that referenced this pull request Jul 23, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants