Ask about a host key where the connection was made, not on the host list

The trust prompt was two banners at the top of the desktop's hosts screen, so the shell navigated there
before letting a handshake raise one: Screen = Hosts, Surface = Page, in OnVaultConnectionFailed and again in
the palette's own connect. The reason was sound — a connection can be started from Ctrl+K on any screen, and
a question behind whatever somebody is looking at is a question nobody can answer — and it was answered the
wrong way round. Rather than making the decision reachable from where the user is, it moved the user to where
the decision was, and charged every screen for it.

It is worst for the one connection that has no host at all. A machine typed into the phone's connect box is
deliberately in no keychain, so a first contact from there judged it on a list it does not appear on, after
taking the box that dialled it away.

So both heads now draw the decision over the surface. HostKeyCard is the desktop's, and is the counterpart of
the phone's HostKeySheet: a scrim with no press handler, because a question with two named answers must not be
answerable by missing; the unknown key offering TRUST AND CONNECT, because judging a fingerprint against what
an operator published is a decision a person is entitled to make and the only moment they can make it; and the
changed key offering no way forward at all, because a button beside that warning is "continue anyway" with two
clicks instead of one. The phone needed no new markup — its sheet was already a shell-level overlay, so
deleting the navigation is what puts it over the Connections screen.

IsHostKeyDecisionShowing is on the shell rather than on a screen because the answer decides an occlusion. A
second connection can be refused while a first one is open, so this card is routinely raised over a live
terminal, and that rectangle is a native child window: layered over it the card would be sliced at its left
edge with TRUST AND CONNECT taking no clicks, which for the most safety-critical question in the product is
the worst place for that class of bug to land. IsTerminalShowing gives the rectangle up instead.

The banners are gone rather than copied. One prompt in two markups is two copies of the most safety-critical
wording here, and the second is the one that goes stale.

TWO DEFECTS FOUND BEHIND IT.

VaultViewModel.RejectHostKey cleared only the pending key and never the mismatch, so the changed-key refusal
had no working exit. That was invisible for as long as it was a banner nothing was drawn over — nothing was
trapped, and the next attempt cleared it — and it was already live on the phone, where that refusal is an
opaque full-screen panel whose one button runs this command: pressing it left the panel up over every screen
the user went to next, including the host editor the panel tells them to open. TransfersViewModel.RejectHostKey
has always cleared both; the vault's was the outlier. Its button said BACK TO HOSTS, which was wrong twice
over, and now says BACK.

And an assertion written for this change could not fail: the palette test asserted the renderer was collapsed
in a scenario whose only tab had just been removed, so it was collapsed for want of a session whatever the
occlusion rule said. It is gone, with a note pointing at the test that can fail on it.

VERIFIED. 1580 tests, build clean, no new warnings, format clean. Three mutations each seen to fail and then
seen green again: dropping !IsHostKeyDecisionShowing from IsTerminalShowing, caught by
AChangedHostKey_CollapsesTheTerminalItIsRefusedOver; reverting RejectHostKey to clear one flag, caught by
RefusingAHostKeyDecision_TakesItOffTheScreen(false) and by that same test; and dropping the two host-key arms
from OnVaultPropertyChanged, caught by TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes.

That last one is the first test in this repository to watch PropertyChanged, and it is worth being the first:
every other assertion about the flag reads it directly, and a direct read passes with the subscription
deleted — while the card would never go away.

The two layout tests moved with the prompts, from the hosts screen to the card. manual-checks gains 7.4a for
the occlusion, 7.4b for getting out of a refusal and 11.7a for the hand-typed case, none of which a test can
see; 1.5 and 7.4 are corrected rather than left describing a window that no longer moves.

ONE ROUGH EDGE, DELIBERATELY LEFT. On the desktop, refusing a first contact whose tab was the only one leaves
the terminal surface with no tabs — a blank rectangle under the strip's "no terminals open · press + or
Ctrl+K", which is the one sentence near that rectangle Avalonia can draw. The alternative was falling back to
the page, and on the phone that means the host list, which is the bug this commit is about. A desktop connect
page would close it properly.
This commit is contained in:
2026-08-06 12:25:37 +02:00
parent f1d6499bb5
commit 4f9faa2fe3
11 changed files with 609 additions and 75 deletions
@@ -484,6 +484,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
// moved out from under it, and nothing in that window can be laid out here — so the connect banner, the
// two host key prompts and the conflict log had never been through this harness at all. They are also
// the four worst candidates for that: each appears only in a state somebody has to reproduce by hand.
//
// The host key prompts have since left this screen for a card over the whole surface, and their two tests
// went with them; see the host key decision below.
[Fact]
public async Task TheHostsScreenFitsWithNothingToAnnounce()
@@ -499,23 +502,42 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the overview showing a host"));
}
// ---- The host key decision ----
//
// ◆ These two were TheHostsScreenFits… tests, because the prompts were banners at the top of that screen
// and the shell navigated there before either could be raised. They are a card over the whole surface
// now — see HostKeyCard.axaml — so they are measured in the rectangle a card gets rather than in a
// screen's rows, and they moved rather than being rewritten: the shapes worth measuring are the same two,
// and each still only appears in a state somebody has to reproduce by hand.
/// <remarks>
/// A full fingerprint is the widest line here and it must not be trimmed — the whole point of the card is
/// that somebody can compare it character by character against what an operator published, and an
/// ellipsis in the middle of one is worse than a card that does not fit, because it looks correct.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWhileAHostKeyIsBeingApproved()
public async Task TheHostKeyCardFitsWhileAKeyIsBeingApproved()
{
vault.PendingHostKey = new HostKeyPresentation(
"db.internal", 22, "ssh-ed25519", "SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the unknown-key prompt up"));
await MeasureHostKeyAsync(faults => faults.ShouldBeEmpty("with the unknown-key decision up"));
}
/// <remarks>
/// The taller of the two, and the one whose height is not this control's to choose: the explanation is
/// composed by the view model out of a host, a port and two full fingerprints, so it wraps to several
/// lines and grows with the length of a hostname.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWhileAHostKeyIsRefused()
public async Task TheHostKeyCardFitsWhileAKeyIsRefused()
{
vault.HostKeyMismatch =
"db.internal:22 presented ssh-ed25519 SHA256:8jkLPQ2mVvTnBqXfWzYc4RdEuHgNsA1oIpKlZbCxMv0, "
+ "and this keychain has SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI pinned for it.";
"The host key for db.production.internal:22 has changed. "
+ "Pinned SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI, but the server offered "
+ "SHA256:8jkLPQ2mVvTnBqXfWzYc4RdEuHgNsA1oIpKlZbCxMv0.";
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the mismatch refusal up"));
await MeasureHostKeyAsync(faults => faults.ShouldBeEmpty("with the mismatch refusal up"));
}
/// <remarks>
@@ -1463,6 +1485,35 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
/// <remarks>
/// The vault is the data context, as it is in the window, and the size is a screen's rather than a card's:
/// this control carries its own scrim and its own <c>Border.card</c>, so what it is handed is the area the
/// overlay covers and the card centres itself inside it. That area is in fact everything under the
/// titlebar, which is <see cref="LayoutHarness.TerminalTabsHeight"/> taller than what is used here —
/// measuring it at the tighter budget is deliberate, since a card that fits the strip's row too cannot
/// stop fitting when the strip is what it is drawn over.
/// </remarks>
private Task MeasureHostKeyAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var card = new HostKeyCard { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the import screen out at the size it gets beside the nav rail.</summary>
private Task MeasureImportAsync(
Action<IReadOnlyList<string>> assert,