Decide what Lock does to a running shell, and say it

Pressing Lock nulled and disposed the vault view model and touched nothing else.
TerminalWorkspace is injected from App.axaml.cs and outlives every lock, so the SSH
connection, the pty and the pump all kept running while the window said "Unlock your
vault" — and since 0500e43 collapsed the WebView while locked, that live session was
invisible as well as unstopped. CloseSessionAsync was reachable in production only from
DisposeAsync, i.e. shutdown. None of this was written down anywhere, so it was neither a
policy nor a bug, which is the actual problem.

Shells now deliberately outlive the lock, and every layer says so.

The reason to prefer this over making Lock a disconnect: locking is what a person does
when they walk away from the machine, which is exactly when a long upgrade, build or
transfer is most likely to be in flight. Ending every shell would make Lock a button that
destroys work, and the predictable response is to stop pressing it and leave the vault
open instead. The idle auto-lock this will grow decides it outright — an unattended
timeout that killed a running job would be worse than the exposure it removes. Closing
the channel also buys less than it looks: the session was authorised at connect time by a
credential the remote verified itself, and no vault key participates in keeping it alive,
so locking cannot retroactively un-authorise it any more than removing a member can.

Stated honestly rather than implied, because the lock screen is what hides it:

- The unlock screen shows how many shells are still connected, and that locking closes
  the vault and not the connections — so a machine still holding authenticated SSH
  channels does not present itself as merely "locked". Shown only when there is something
  to disclose. Quitting is what ends them, and the text admits that.
- The Lock button carries the same thing in a tooltip, since its name implies the
  opposite of what it does to a shell.
- README lists it as a third architecture consequence beside non-retroactive revocation,
  which is the same shape of honest limit; docs/crypto.md §10 records it as a threat-model
  boundary; TerminalWorkspace and LockAsync carry the argument next to the code.

LiveSessionCount deliberately does not count dictionary entries. Nothing removes a
session when the remote closes the channel by itself — RunSessionAsync only drops the
renderer registration — so sessions.Count would report a shell that exited half an hour
ago as still running, on the one screen where a user is deciding whether it is safe to
walk away. A completed Run task is what "the shell is gone" actually looks like. While
locked the number can only fall, since opening a session needs the vault, so a stale
value over-reports rather than under-reports.

Both new tests fail when the policy is reverted: the count test times out against
sessions.Count, and the shell test reports "workspace.LiveSessionCount should be 1 but was
0" when Lock closes sessions. ShellFlowTests also stops building its workspace with a real
SshNetConnectionFactory that nothing ever called, which had made the suite's independence
from the network a coincidence rather than a property.

Verified by hand with a live shell, which nothing had done: a harness mirroring
MainWindow.axaml's 340,* grid with a real NativeWebView, the shipped WebAssets, a real
sshd in a container, and an ISshShellSession decorator recording every window-change the
remote is actually told about. Across lock and unlock, no window-change reached the remote
at all, stty size answered 50 118 before and after, the renderer's own buffer came back
byte for byte with the wrapped line intact, and the session stayed live throughout. A
control run that never hides the WebView behaves identically, so nothing above is startup
or idle behaviour. Keystrokes injected while locked reach nothing: twelve of twelve
SendInput events accepted with the harness confirmed as the foreground window, no probe
character in the remote's output, and a following Ctrl-U answered BEL, so nothing was
queued in the line editor either. A hidden WS_CHILD window is not eligible for keyboard
focus, which is what makes surviving the lock defensible rather than merely convenient.

Correction to a claim made in f80b3d4: terminal.js's guard comment listed "a host that
hides the WebView while the vault is locked" among the paths that reach a degenerate fit.
It does not. Collapsing the control hides a native child window without resizing it, so
the page still reports paneWidth 840 and paneHeight 760 with unchanged cols and rows, no
ResizeObserver callback fires and the fit never runs. Establishing that rather than
assuming it: the same cycle with MINIMUM_FITTABLE_PIXELS patched to 0 — the guard fully
disabled — is equally clean. The guard is still right for minimising and for a splitter
dragged to the edge; it is simply not what makes locking safe, and must not be cited as
though it were.

Recorded, not fixed:

- Nothing closes one terminal from the interface, so a user reading "1 shell is still
  connected" can only act on it by quitting. CloseSessionAsync is tested and correct;
  VaultViewModel discards the session id it would need.
- A session whose remote exits keeps its ISshConnection, and the thread ShellStream parks,
  until the process ends.
- Suspected and seen once: before the harness waited for the window's scale to settle, a
  DPI settle pushed a 2202x1328 pane for a window 1180 logical units wide and a later
  re-push reflowed the wrapped line. Three later runs at RenderScaling 1.00 never showed
  it, so it is filed as a lead, not a finding.
- WebView2 fails to initialise with CO_E_SERVER_EXEC_FAILURE when the host executable
  sits under a very long path. Cost an hour on the harness; relevant to packaging.
This commit is contained in:
2026-07-29 14:44:02 +02:00
parent f80b3d4351
commit 5a899afd78
11 changed files with 525 additions and 11 deletions
@@ -139,6 +139,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private VaultViewModel? vault;
/// <summary>
/// Shells that were left running when the vault was locked.
/// </summary>
/// <remarks>
/// Refreshed by <see cref="LockAsync"/>, which is where the policy this reports is explained.
/// </remarks>
[ObservableProperty]
private int liveSessionCount;
internal bool HasLiveSessions => LiveSessionCount > 0;
/// <summary>The count as a sentence, because a bare number on a lock screen explains nothing.</summary>
internal string LiveSessionSummary => LiveSessionCount == 1
? "1 shell is still connected and still running."
: $"{LiveSessionCount} shells are still connected and still running.";
/// <summary>Where the embedded browser should navigate.</summary>
internal Uri TerminalPageUrl => workspace.PageUrl;
@@ -335,7 +351,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}).ConfigureAwait(true);
}
/// <summary>Closes the vault and forgets every key it held.</summary>
/// <summary>
/// Closes the vault and forgets every key it held. Open shells keep running.
/// </summary>
/// <remarks>
/// <para>
/// <b>Lock is a vault operation, and deliberately not a disconnect.</b> The reason a person locks is
/// that they are walking away from the machine, which is exactly the moment a long upgrade, build or
/// transfer is most likely to be in flight — so killing every shell would make Lock a button that
/// destroys work, and the predictable response is to stop using it and leave the vault open instead.
/// The same argument decides it for the idle auto-lock this will grow: an unattended timeout that
/// terminated a running job would be worse than the exposure it removes.
/// </para>
/// <para>
/// <b>What "locked" therefore describes.</b> Disposing the vault zeroes the identity keys, the vault
/// keys and the cache key, so nothing on disk can be read without the passphrase again. It says
/// nothing about this machine's access to remote hosts: an SSH channel authenticated at connect time
/// needs no vault key to keep running, and the credential it used was already spent. Locking cannot
/// retroactively un-authorise a session any more than revocation can — the same honest limit the
/// README records for a removed team member. So a locked DodoSSH still holds open, authenticated
/// channels, and <see cref="LiveSessionCount"/> is shown on the unlock screen rather than left to be
/// inferred from a terminal that the lock screen hides.
/// </para>
/// <para>
/// The count is a snapshot taken here. While locked it can only fall — opening a session needs the
/// vault — so a stale value over-reports and never under-reports, which is the safe direction for a
/// warning of this kind.
/// </para>
/// </remarks>
[RelayCommand]
private async Task LockAsync()
{
@@ -345,6 +388,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
await open.DisposeAsync().ConfigureAwait(true);
}
LiveSessionCount = workspace.LiveSessionCount;
State = ShellState.Locked;
StatusMessage = "Locked.";
}
@@ -464,6 +509,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return exception.Message;
}
partial void OnLiveSessionCountChanged(int value)
{
OnPropertyChanged(nameof(HasLiveSessions));
OnPropertyChanged(nameof(LiveSessionSummary));
}
partial void OnStateChanged(ShellState value)
{
OnPropertyChanged(nameof(IsStarting));
+23 -1
View File
@@ -68,7 +68,12 @@
<Button Content="Sign in" Command="{Binding SignInCommand}"
IsVisible="{Binding !IsOnline}" />
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
<Button Content="Lock" Command="{Binding LockCommand}" />
<!--
The tooltip carries the policy to the point of action, because the button's name implies
the opposite of what it does to a running shell.
-->
<Button Content="Lock" Command="{Binding LockCommand}"
ToolTip.Tip="Closes the vault and forgets its keys. Open shells keep running and reappear when you unlock." />
</StackPanel>
</Grid>
</Border>
@@ -312,6 +317,23 @@
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
<TextBlock Classes="hint" FontSize="11"
Text="This works with no network: the salt and the wrapped key are already on this machine." />
<!--
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed
while locked, so a shell left running is invisible as well as unstopped — and a screen
saying "Unlock your vault" over a machine that still holds authenticated SSH channels is
exactly the kind of half-truth this project writes down instead of implying. Visible only
when there is something to disclose, so an ordinary launch stays quiet.
-->
<Border Background="#1b2432" CornerRadius="4" Padding="10,8"
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
<StackPanel Spacing="4">
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="#bcd2ea"
FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11"
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
</StackPanel>
</Border>
</StackPanel>
</Border>
+11 -6
View File
@@ -135,12 +135,17 @@ function activate(sessionId) {
}
}
// Below this, a pane is not being looked at — it is minimised, dragged to nothing, or the host has
// hidden its window. Fitting anyway would be actively harmful rather than merely useless: the fit addon
// floors its proposal at 2 columns by 1 row, so a degenerate viewport reflows the *remote* pty to 2x1
// through window-change, and the wrapped scrollback that produces cannot be recovered when the pane comes
// back. A guard rather than a fix for one caller, because several paths reach here a minimised window, a
// splitter dragged to the edge, and a host that hides the WebView while the vault is locked.
// Below this, a pane is not being looked at — it is minimised or dragged to nothing. Fitting anyway would
// be actively harmful rather than merely useless: the fit addon floors its proposal at 2 columns by 1 row,
// so a degenerate viewport reflows the *remote* pty to 2x1 through window-change, and the wrapped
// scrollback that produces cannot be recovered when the pane comes back. A guard rather than a fix for one
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
// once splits land.
//
// It is *not* what protects the vault's lock screen, which an earlier version of this comment claimed.
// Collapsing the host's WebView hides a native child window without resizing it, so this page's viewport
// does not change, no observer fires and this function is never called — measured with a live shell, and
// confirmed by removing the guard and finding the lock cycle equally clean. See docs/platform-flags.md.
const MINIMUM_FITTABLE_PIXELS = 40;
function resize(session, sessionId) {
@@ -6,10 +6,19 @@ namespace DodoSSH.Client.Terminal;
/// Owns the loopback data plane and every live terminal session.
/// </summary>
/// <remarks>
/// <para>
/// One data plane and one renderer page for the whole application, with a session id per terminal.
/// Not one WebView per tab: each WebView2 is a separate browser process, so twenty tabs would mean
/// twenty renderer processes and several hundred megabytes for a working set a user would call
/// ordinary. Splits and tabs are layout inside the single page.
/// </para>
/// <para>
/// <b>A session's lifetime is the application's, not the vault's.</b> This object is composed once at
/// startup and outlives every lock, deliberately: locking the vault zeroes keys, and a shell needs no
/// vault key to keep running, so a job started before the lock keeps running through it. That is a
/// policy rather than an oversight — <c>MainWindowViewModel.LockAsync</c> says why, and the shell shows
/// <see cref="LiveSessionCount"/> on the unlock screen so it is not a hidden state.
/// </para>
/// </remarks>
public sealed class TerminalWorkspace : IAsyncDisposable
{
@@ -37,6 +46,25 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// <summary>Where the WebView should navigate.</summary>
public Uri PageUrl => dataPlane.PageUrl;
/// <summary>
/// How many terminals still have a live shell behind them.
/// </summary>
/// <remarks>
/// <para>
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes
/// the channel on its own — <see cref="RunSessionAsync"/> only drops the renderer registration — so
/// a session whose shell exited half an hour ago is still in the dictionary. A completed
/// <c>Run</c> task is what "the shell is gone" actually looks like: the pump's loops have finished
/// and it has already sent <c>SessionClosed</c> to the renderer.
/// </para>
/// <para>
/// This exists because the shell shows the number on the unlock screen, and a lock screen that
/// claims a shell is still running when it is not would be the same class of dishonesty the number
/// is there to prevent.
/// </para>
/// </remarks>
public int LiveSessionCount => sessions.Values.Count(session => !session.Run.IsCompleted);
/// <summary>Starts the loopback listener.</summary>
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
@@ -84,7 +112,15 @@ public sealed class TerminalWorkspace : IAsyncDisposable
return sessionId;
}
/// <summary>Closes one terminal.</summary>
/// <summary>
/// Closes one terminal.
/// </summary>
/// <remarks>
/// Reached only from <see cref="DisposeAsync"/> today, which is a consequence of the lifetime policy
/// above rather than an accident: nothing else in the application ends a session, because locking
/// deliberately does not and there is no per-tab close in the interface yet. It is here, and tested,
/// because closing one terminal without taking the process down is what a tab close needs.
/// </remarks>
public async Task CloseSessionAsync(uint sessionId)
{
if (!sessions.Remove(sessionId, out var session))