Skip to content

Show users a fixed phrase when a target connection fails, not the error - #2548

Open
janisdombr wants to merge 28 commits into
warp-tech:mainfrom
janisdombr:upstream-fix/desktop-error-forwarding
Open

janisdombr wants to merge 28 commits into
warp-tech:mainfrom
janisdombr:upstream-fix/desktop-error-forwarding

Conversation

@janisdombr

@janisdombr janisdombr commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

When a connection to a target failed, the user was shown the error's own words. A real ssh client saw Target connection failed: Connection refused (os error 61); a browser watching a VNC session saw whatever the decoder said, which for its IoError variant is #[error(transparent)] over std::io::Error.

Every leg now goes through one conversion. Both client_message() methods return a fixed phrase per error kind. SshClientError::client_message() is a const fn returning &'static str, so the type rules out runtime text; ConnectionError::client_message() returns String, and its fixed phrases are guaranteed by the match arms rather than by the signature. The PTY leg, the web terminal and the desktop legs all use it. Two paths were hardened only on one side and are now finished: the SSH PTY leg, whose doc comment already claimed to cover it, and the VNC mid-session path, where map_event passed the decoder's string straight through while the connect-time path beside it was already sanitised.

Measured with a real ssh client against an unbound port. Before: Target connection failed: Connection refused (os error 61). After: Target connection failed: SSH protocol error, with the OS reason still in the log.

Tests: 91 unit, plus tests/test_ssh_terminal_error_disclosure.py, which runs a real Warpgate and a real OpenSSH client. Every seam was checked by removing it and confirming the test goes red. One test could not fail and was repaired: it asserted a value the fallback also returns, so deleting the downcast entirely left it green. The browser leg has no end-to-end test and the module doc says why: a connect-time failure tears the web session down before a websocket can attach, and a mid-session death never produces that error at all.

Carries #2584: disconnect_server queued the connection-level Disconnect behind the channel closes, so a client handed both in one read exited without printing the phrase this PR installs. On main the test here fails 2 of 10 local runs and has failed twice on CI (Received disconnect from ::1 port N:11: and nothing before it); with #2584's two commits, 0 of 10. #2584 is closed in favour of this one.

Fork CI and CodeRabbit: janisdombr#9

Description

...

AI Usage

Choose the level of AI involvement for this PR.

  • Fully vibe coded
  • AI-designed, AI-coded, manually checked
  • Human-designed, AI-coded
  • Human-designed, human-coded (includes AI autocompletions and boilerplate gen)

This is not to block AI contributions but rather to speed up PR review (saves time on trying to deduce the logic behind AI hallucinations).

RDP forwarded the full anyhow chain, VNC only the top-level cause, and
web-ssh forwarded RCEvent::Error/ConnectionError verbatim (including a
raw WarpgateError, e.g. a DB error) — three different, arbitrary
answers to the same question. One conversion point per protocol now
decides what the viewer is owed (the top-level cause for desktop
protocols, a fixed safe message for SSH's typed errors), while the
full chain still goes to the log.

The terminal is the same sink. `RCEvent::ConnectionError` and
`RCEvent::Error` in the SSH session wrote the error's own `Display`
onto the user's PTY, so hardening only the browser leg would leave an
identical path open beside it — and `ConnectionError::Warpgate` is
`#[error(transparent)]`, so that path rendered `WarpgateError` itself.
One hardened path and one unhardened path to the same sink is not a
boundary.

What crosses that boundary is now a named function rather than a call
inside an event loop, because the thing worth guarding is the choice —
`client_message()` and not `Display` — and a call in an async loop
cannot be reached by a test without driving a whole browser session.
The tests assert on `Warpgate` specifically, first that the fixture's
own text carries the leak and then that the shown text does not, so
they cannot pass by testing nothing.

Also raises the SSH target-connect failure log from debug! to error!:
it was the only server-side record of a user-visible connect failure,
and at the default level there was none at all. The log lines take the
error by `Debug`, not `Display`: `RCEvent::Error` carries an
`anyhow::Error`, whose `Display` is only the outermost context, and
the promise made here is that the whole chain reaches the log.
`sanitising_survives_a_context_wrapper` asserted that a wrapped
`SshClientError::Warpgate` renders as "Internal error in the target
connection". That is also what `client_error_message` returns when the
downcast finds nothing, because `client_message()` maps the `Warpgate`
variant to the same string as the fallback. The assertion therefore could
not tell a downcast that walked the context chain from the exact
degradation the test's own comment says it exists to catch: deleting the
downcast entirely and returning the fallback unconditionally left all
three tests in the module green.

Assert on `MpscError` instead, whose message only a successful downcast can
produce, and keep the leak direction as a second assertion on the original
fixture. Under the same mutation the test now fails.
`spawn_client` routes a connect-time failure through
`DesktopEvent::backend_error`, whose doc calls itself "one conversion point
for both RDP and VNC". It is not: `map_event` carries a mid-session
`VncEvent::Error` to the same sink and passed the payload through untouched,
reaching a browser verbatim via `warpgate-web-desktop`'s `From<DesktopEvent>`.

The payload is not ours to shape. The vendored decoder emits it from its
decode loop for anything that is not a clean EOF, and `vnc::VncError::IoError`
is `#[error(transparent)]` over `std::io::Error`, so the string is whatever
the OS said. There is no structured error here to take a top-level cause
from, so the viewer gets a fixed phrase and the log gets the text.

The native VNC proxy was never exposed to this: its consumer discards
`DesktopEvent::Error` text rather than displaying it. That is a property of
that one consumer, not a second guard, and it is not what the browser path
does.
A target whose port nothing is listening on, reached over a real SSH session
through a running Warpgate. `-tt` is not decoration: `emit_pty_output` writes
only to channels that carry a PTY, so a plain `ssh host command` never sees
any of this.

Against the parent commit the terminal reads

    ● Warpgate: Target connection failed: Connection refused (os error 61)

and the test fails on it. The reason still has to reach the log, which is
asserted too -- otherwise the same test would pass on a change that discarded
the error rather than relocating it.

The browser leg is not here, and the module doc says why rather than leaving
it looking like an oversight: a connect-time failure tears the web-SSH session
down before a websocket can attach (a GET on the session id 404s immediately
after the POST that created it returned 201), and a mid-session death does not
produce `ConnectionError` at all -- the connection was established, so it
arrives as `Drop for ClientHandler` -> `State(Disconnected)`. That arm keeps
its unit test.
The added comments ran at about eight times the density of the code around
them: 21% of added lines against 2.5% across the crate. Most of it narrated
what the code already says, or repeated the same non-vacuity note in three
slightly different wordings.

What stays is the part a reader needs in order not to undo the change: why
the transparent variants cannot be rendered to a user, why the boundary is a
named function rather than a call in the loop, why the context-wrapper test
uses a fixture whose message differs from the fallback, and why the VNC live
loop needed the same treatment as the connect path.
janisdombr and others added 11 commits September 14, 2026 03:22
`disconnect_server` queued the connection-level `Disconnect` behind the
channel closes, so it left for the client in the same breath as whatever the
session had just said. A client handed both in one read acts on the disconnect
and exits without printing what it already holds, and the session's last words
are lost — which is the one thing those messages exist to prevent.

A session closed for inactivity says so and disconnects in the next statement,
so that path lost its notice every time: the client saw the target's output and
then nothing.

The disconnect now goes out from the task that already waits out the flush
grace before closing the socket, and only to a client that is reading; one that
is not still gets cut immediately. The channel closes stay in the queue, so
per-channel ordering is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TargetDataRequest gained three required booleans and the SSH options
one more while this branch was open; the new test constructed the
target without them and would fail validation before reaching the
behaviour it checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gy1bTv7ou9aRZG8UrNEJHi
The connection-level disconnect now leaves from the shutdown task, so the
queue variant and its enqueue method had no callers left.
janisdombr added a commit to janisdombr/warpgate that referenced this pull request Sep 22, 2026
A Vault error body arrives verbatim — bounded and UTF-8-repaired, but with its
control characters intact — and a newline in it forges a whole record in the
default text format that a reader cannot tell from one Warpgate wrote. The
connect path already rendered this value with `{:?}` and said why; the session's
sink for the same value — it arrives there as `RCEvent::ConnectionError`, sent
from that very line — used `{}`, so every forgery the first sink refused went
through the second untouched.

Escaped at the type and at the sink, because one sink is not the property. The
`Api` variant renders its body with `{body:?}`, which is what makes web-ssh's
`%e` and every sink added after it safe without editing any of them; the field
stays raw, so `client_message` still classifies on its text and nothing else
reads the rendered form. The session's sink keeps `{:?}` for the rest of the
enum, whose transparent variants hand their inner text to the log as written.

The call moves into `log_target_connection_failure`, named the way web-ssh's
`shown_to_the_browser` is, so a test has somewhere to stand that a call inside
the event loop does not. `#[deny(dead_code)]` ties the two back together: `mod
tests` is `#[cfg(test)]`, so a call site that goes back to logging inline fails
an ordinary build instead of orphaning the helper while its test stays green.

The regression test's fixture is a transparent variant rather than the Vault one
it was written against — Vault's body is escaped by its own Display now, so that
fixture would pass with the sink reverted and prove nothing about it. The Vault
case stays as a second test of the path end to end: it holds while either layer
does, which is what having both is for.

web-ssh's two `error=%e` sinks and `RCEvent::Error` are deliberately left alone.
warp-tech#2548 changes all three, and after this the Vault body — the only hostile text
this branch introduces — is escaped before it reaches them anyway; editing them
here would only manufacture a conflict.

Guards 57 and 58 in the mutation matrix anchor on the sink and on the rendering.
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.

1 participant