Merge branch 'main' into the desktop updater, and give way on two numbers

Main landed a realtime push feature while this branch was building the updater,
and the two collided in three places. Every one of them resolves the same way:
main got there first, so this branch moves.

**Two ADRs were both numbered 0012.** Main's is realtime push; this one is now
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call
this a conflict — the filenames differ — so it would have merged quietly and left
the directory with two 0012s and every cross-reference ambiguous. Renumbered here
along with the nine places that point at it.

**Two manual-check phases were both numbered 15**, and that one git did catch.
Main's "Changes that arrive without a timer" keeps 15; installing and updating
the desktop client becomes Phase 16, with its checks and every reference to them
renumbered. The file's own rule is that a number is for life, which is exactly
why the one that had not been pushed is the one that gives way.

**The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on
everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and
`EnforceCodeStyleInBuild` is on, so it failed the build on three lines of
App.axaml.cs whose only change in this branch was an ADR number in a comment.
Forty-six files normalised back to LF; the release script keeps CRLF, which is
what `.gitattributes` and `.editorconfig` both already say for a PowerShell file.

Nothing else conflicted. The updater does not touch the sync loop or the event
stream, and the one file both sides edited heavily — MainWindowViewModel — merged
without a hunk in common.

Verified after merging: the solution restores locked and builds clean, and 304
shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The
first two counts are higher than before the merge because main's own tests came
with it and pass alongside these.
This commit is contained in:
2026-08-04 17:52:57 +02:00
52 changed files with 6205 additions and 341 deletions
+98 -11
View File
@@ -118,7 +118,7 @@ signing certificate, so Windows SmartScreen shows *"Windows protected your PC"*
installer; **More info → Run anyway** gets past it. That is the honest state of things rather than something
to click through blindly — it is a statement that Microsoft has not seen this file before, and it will stop
appearing when the project buys a certificate.
[ADR 0012](docs/adr/0012-desktop-distribution-and-updates.md) says what that costs and when it happens. The
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) says what that costs and when it happens. The
warning is once per person: updates from inside the application do not raise it.
**Updates.** The client checks the project's release page every six hours, downloads a newer build in the
@@ -140,7 +140,7 @@ you want the machine to genuinely forget everything — an uninstall is not a si
this machine's device key from your account.
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
CI job; ADR 0012 decision 3 explains why, and it is not only that the runners are Linux.
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
## Running it
@@ -377,14 +377,75 @@ Six limits, stated rather than discovered:
into it that is not there.
Items are filed into one vault at a time, and which one is asked at the moment the item is made. **A host's
editor has its own picker**, beside the name, because that is the decision that cannot be undone: the two
vaults are encrypted under different keys, so moving an item afterwards means deleting it and typing it
again — and the picker is therefore absent when you edit an existing host rather than present and refusing.
Keys, passwords and buckets take theirs from a standing "new items go to" picker on the Keychain screen.
editor has its own picker**, beside the name, because it is the decision on that form that decides who can
read the host. It is absent when you edit an existing one rather than present and refusing, and that is not
because the host is stuck: **a host can be moved to another vault** — "Move to another vault…" in the detail
pane's menu on the desktop, MOVE beside EDIT on the phone. It is a separate act because it is not a save.
The two vaults are encrypted under different keys, so a move is a re-seal into one and a tombstone in the
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
What a move cannot do is reach a machine that has already synced the host, which is the same limit
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
to" picker on the Keychain screen and cannot be moved yet.
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
the desktop. That is the desktop only, because the phone draws groups as headings in the host list and has
never had a way to delete or move one. It is the same re-seal
and tombstone underneath, applied to every item involved: the group, the groups nested inside it, and every
host filed under any of them, each taking a new id in the destination. Moving less than that was never
coherent — the machines and the child groups are items of the vault the group is leaving, so a group that
travelled alone would leave half a shelf behind. What stays is the group it was itself nested under, which
belongs to the old vault, so it arrives at the top level; the hosts' tags stay for the same reason. Keys and
passwords are kept, because those genuinely resolve across vaults, and the sentence afterwards names any
that are now outside the destination.
**Deleting a group asks what should become of the hosts under it.** The default answer keeps them: the
reference is cleared and they move to UNGROUPED. Ticking the box deletes them with it. Both answers are a
change — the deletion used to leave the hosts holding an id that no longer resolved, which looked the same
and cost nothing, and stopped being the right shape once the deletion could take them with it. A group with
nothing filed under it is not asked. Whichever answer is given, the groups nested inside take the deleted
group's place in the tree rather than being orphaned to the top level.
Both default to your personal vault and neither moves on its own, because an item put in a shared vault is
visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups
it can be filed under: a group is an item like any other and lives in exactly one vault.
### Changes that do not wait
A client holds a WebSocket open to the server — `GET /api/v1/events`, subprotocol
`dodossh.events.v1` — and the server sends a line down it whenever something you can read has moved. The
client's answer is the same delta pull it would have run on its timer, only now rather than in up to a
minute. Two things you can see: an edit somebody else makes appears while you are looking at the list, and
a vault shared with you turns up as soon as they share it.
**What is on that socket is a notice, not your data.** A frame says which vault changed and how far its
change log has got, and nothing else: no item, no ciphertext, not even which item it was. That is the
decision the rest of this rests on, and it is deliberate twice over — the server has nothing else it
*could* send, and keeping it that way means there is still exactly one path that applies a change to your
keychain, so the socket can be wrong or absent without anything being applied incorrectly.
**Polling is still there and is still what guarantees a pass.** The minute timer is unchanged. A network
that eats WebSockets, a server with `Events:Enabled` off, an older server, a proxy that will not upgrade, a
notice dropped because your machine was too slow to read it — every one of those leaves you with exactly
what this product did before the socket existed. Nothing is reachable only this way, and nothing is
supposed to become so.
Three limits are worth knowing rather than discovering:
- **One node.** Fan-out is in-process, so a deployment running more than one API replica only pushes for
writes that its own replica handled. The rest arrive on the timer. The seam for a PostgreSQL
`LISTEN`/`NOTIFY` backplane is in place and is not implemented, because an untested backplane would be
worse than a documented gap.
- **The socket does not outlive your access token.** It is closed at the token's expiry and the client
reconnects with a fresh one, which is a gap you will not see. That, plus re-reading your vault list every
few minutes, is what bounds how long a withdrawn grant can keep producing notices — and what it bounds is
*metadata*, because reading a vault needs a key the server has never held.
- **You are told about your own writes.** Your client pushed, so it has already pulled; the extra pass finds
nothing. Notices are coalesced over a quarter of a second so that a burst is one pass rather than a dozen.
The reasoning, including why this is a WebSocket rather than server-sent events and where a shared terminal
session will attach to it, is in [ADR 0012](docs/adr/0012-realtime-push.md).
### The Android head
`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one — the
@@ -632,6 +693,16 @@ keychain plus a terminal — and the spike that gates all of it.
than to a shell that never opens. Given a walk that had to be cycle-safe anyway, refusing to nest bought
nothing.
Membership living on the host has one further consequence, and it took two goes to settle. Deleting a
group could not clear it without rewriting every host under the heading, so at first it did not: the hosts
kept an id that resolved to nothing and turned up under UNGROUPED, which reads identically and costs no
writes. That held until the deletion had to be able to take the hosts *with* it — a group is sometimes a
heading being tidied away and sometimes a project that has been decommissioned, and nothing in the code can
tell which. Once a deletion knows which hosts it means, leaving them naming something that has gone is a
state kept for no reason, so both answers now write: N deletions, or N hosts with the reference cleared.
The dangling case still has to be survived everywhere it is read, because a group deleted on *another*
machine arrives exactly that way.
Inserting a snippet types it at the prompt and stops. Pressing Enter is a per-snippet decision, off by
default, and the reason is worth stating: a terminal is one input stream with no notion of being at a
prompt — the remote may be in an editor, or at a password prompt with the echo off — so this client cannot
@@ -658,6 +729,20 @@ keychain plus a terminal — and the spike that gates all of it.
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from
the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a
ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
*Realtime done:* a WebSocket the client holds open, over which the server says which vault has moved so a
pull happens now rather than within the minute. What crosses it is a notice and never an item, which is
what keeps one code path applying changes and makes a dropped socket cost latency and nothing else — the
timer is unchanged and is still the guarantee. Two limits are stated rather than implied: fan-out is
in-process, so a multi-replica deployment falls back to the timer for writes another replica handled, and
a socket is closed at its access token's expiry rather than outliving the credential that authorised it.
See [Changes that do not wait](#changes-that-do-not-wait) and
[ADR 0012](docs/adr/0012-realtime-push.md).
It is also the transport a **shared terminal session** will use — one person's shell, watched or driven by
somebody else. Nothing of that exists yet, and ADR 0012 records the one decision made early so it need not
be renegotiated: session data will be binary frames on this same socket, because base64 in a JSON envelope
is the wrong shape for the one payload here that is continuous rather than occasional.
- **M3 — shared vaults**, sharing, ACLs. *Done.* Membership with roles, a public-key directory, the
append-only key log served for clients to verify against, shared vaults, and vault key grants
wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
@@ -681,10 +766,12 @@ keychain plus a terminal — and the spike that gates all of it.
the rotation is re-sealed as it is pushed, so nothing reaches the server under a superseded key at all.
See [ADR 0010](docs/adr/0010-vault-key-rotation.md).
**A vault shared with you arrives on the next synchronisation pass**, within the minute, with no sign-in
and nothing to press. There is no push channel, so each pass asks the server which vaults this account can
reach before syncing the ones it already knows — which is also how a vault that has been deleted, or one
whose grant was withdrawn, stops being listed.
**A vault shared with you arrives at once**, with no sign-in and nothing to press. Each pass asks the
server which vaults this account can reach before syncing the ones it already knows — which is also how a
vault that has been deleted, or one whose grant was withdrawn, stops being listed — and the server now
says so the moment somebody wraps a key to you rather than leaving it for the next pass. Without a
reachable socket that becomes "within the minute", which is what it always was; see
[Changes that do not wait](#changes-that-do-not-wait).
**Ownership transfer is here, and it is one write rather than two.** The member you name becomes owner
and you become an admin, in a single transaction — because ownership is sole, so promoting first leaves
@@ -708,7 +795,7 @@ keychain plus a terminal — and the spike that gates all of it.
the first time the API has reported a true `serverVersion`. Releases are cut by a person rather than by
CI: the token that writes a release is, for an updater that trusts its feed, the same capability as the
signing key, which [ADR 0011](docs/adr/0011-android-distribution.md) rule 1 keeps off runners. See
[ADR 0012](docs/adr/0012-desktop-distribution-and-updates.md), and
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
[Installing on Windows](#installing-on-windows) for what a user sees.
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the