Commit Graph
100 Commits
Author SHA1 Message Date
jaap-jan 6fe2b21781 Point every build at the hosted server, not just the ones that ship
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 16s
ci / android head (push) Canceled after 0s
The default was split on DEBUG so a clone would offer localhost and only an
installed build would offer ssh.dodotech.cloud. That protected development
launches from enrolling a device against production. It also meant the address
in the box depended on how the binary was built, which is not what was wanted.

Working against a local API now means typing http://localhost:5233 by hand.
2026-08-03 14:27:01 +02:00
jaap-jan 416f233657 Let the lock screen wear the theme instead of its own copy of it
ci / build and test (push) Successful in 1m13s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 23s
Four controls on this screen carried a full set of metrics each, and three of
them were a shared class rewritten by hand — UNLOCK is Button.primary down to
the font weight, the fingerprint button is Button.secondary, the passphrase box
is TextBox.field. Copies drift, and every difference between these and the
originals was drift rather than intent: corner radii of 6 from the first design
where v2's ladder says 9 for a button and 11 for a field, a label half a point
small, and the missing content alignment that put UNLOCK's word against the top
of its box and started this.

Two things fall out that are worth naming, because neither was the reported
fault. UNLOCK now has the pressed and disabled states the class carries, and
before it had neither — pressing it ran Fluent's default, which replaces the
accent fill with a theme grey, so the one button this application most wants to
look deliberate flashed the wrong colour under a thumb. And the passphrase box
is 44 rather than 48, which is what every other box on the phone is; the four
pixels bought it nothing that the button under it does not already give the
thumb.

RESET keeps two properties of its own against Button.danger, and they are the
two that carry meaning. That class is a DELETE beside a KEEP, where the red
outline says which of two offered actions costs something. Here there is no
pair, and a bordered red button under UNLOCK reads as the next thing to try
rather than as the last resort the screen means it to be.

The live-sessions disclosure is not a class anywhere — warn cards are plain
borders on all three screens that draw one — but its radius was 6 against the
12 the other two use, so it joins the ladder without joining a class.
2026-08-03 14:08:30 +02:00
jaap-jan 6fb82a111e Sit UNLOCK's word in the middle of the button it is drawn on
A button's content alignment defaults to Stretch and the Fluent template binds
it straight through, so a text block filling a 48-pixel button draws its line
along the top edge of it rather than across the middle. That is why all seven
button classes in Phone.axaml set VerticalContentAlignment, and it is why this
one did not: the lock screen is the only place on the phone whose buttons carry
their own metrics rather than a class, and it was written with the horizontal
half of the pair and not the vertical.

Its two neighbours are unaffected and not for the same reason, which is worth
knowing before this is read as a pattern: RESET has no fixed height and a
symmetric vertical padding, and the fingerprint button's content is a panel
that stretches and centres its own children. Only the one with a bare string in
a fixed-height box shows it.

Left as its own set of metrics rather than folded into Button.primary, which it
otherwise duplicates down to the font weight. The two differ in corner radius —
6 here against the 9 the v2 ladder gives a button — and changing that is a
design decision rather than a fix to the thing that was reported.
2026-08-03 14:04:58 +02:00
jaap-jan ce86a4ff72 Look the panel up, because the field the generator declares is never filled
ci / api image (push) Successful in 34s
ci / build and test (push) Successful in 1m17s
ci / android head (push) Failing after 5s
The last commit crashed the phone before its first frame. `PhoneShell` took the
`Body` field that Avalonia's name generator declares for `x:Name`, and that
field is assigned in the generated `InitializeComponent` — which no view on
this head calls, all of them loading their XAML directly instead. It compiles,
it is null at run time, and this control dereferences it in its constructor.

`TerminalScreen` has been looking its two named controls up with `FindControl`
since it was written, for exactly this reason. This does the same, and the
remark on the field now says why so the next person reaching for the shorter
spelling finds the answer before the device does.

Confirmed rather than guessed: the generator's output for this control is one
method, and the assignment is inside it.
2026-08-03 14:01:07 +02:00
jaap-jan 35387b1c9d Tell the phone's keyboard these are secrets, and get it off the box
ci / api image (push) Successful in 21s
ci / build and test (push) Successful in 1m22s
ci / android head (push) Failing after 5s
Five boxes on this head take a secret and every one of them was drawing dots
and saying nothing. `PasswordChar` is a screen property: Windows has no opinion
about what is being typed into a text box, so the desktop head needs nothing
more. Android's software keyboard has an opinion, and left at its default it
read a vault passphrase as prose — completions offered in the suggestion strip
above the box, and the passphrase itself learned into the IME's dictionary.
Dots on screen with a word bar over them is the worst of both: hidden from the
person typing it and offered to the room. `TextInputOptions.ContentType` is the
property the Android backend maps onto `InputType`, and it is what turns both
off. Both attributes now live in one `TextBox.secret` class rather than being
repeated per box, because they are two halves of one fact and the next box
added would have got one of them.

The keyboard also went on covering whichever box had raised it. That is in
`PhoneShell` rather than on each screen: everything the phone draws is inside
its one root panel, so a bottom margin shortens all eleven screens at once, and
a screen added later cannot forget to handle something it never had to know
about.

Two mechanisms, and it matters that neither is a backstop for the other. Before
Android 15 the activity now declares `AdjustResize` and the platform shortens
the window itself; left unspecified Android chooses, and what it chooses for a
window whose entire content is one native view — which is what an Avalonia
surface is — is to pan, sliding the window by however much it thinks the
focused native view needs and leaving the box exactly where it was. That was
the bug. From Android 15 the attribute is ignored, edge-to-edge being enforced
and the window no longer resized for the keyboard at all, and the reported
inset is what there is. Each is dead where the other applies — where the window
resizes, the inset arrives already consumed and measures zero — which is why
the margin comes from the inset alone. Both added together would strand the
interface an entire keyboard above the keyboard.

Scrolling the box back into view keys off the size change rather than off
either mechanism. `ScrollViewer` already brings a newly focused child into
view; what it cannot know is that the visible region shrank after the focus,
and both ways of losing that region end in the same resize.

None of it is reachable by a test. The software keyboard is an inset the
platform reports and a headless top level reports none, so phase 10 of
`docs/manual-checks.md` is the whole of the verification — including the note
to run it on one device each side of Android 15, since a build exercised on
only one of the two will look correct and be half broken.
2026-08-03 13:55:08 +02:00
jaap-janandClaude Opus 5 16e0051e89 Draw the tags that have been storable and invisible since the domain landed
ci / build and test (push) Successful in 1m22s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 52s
`Tag` has been a full item kind for three commits — a table, a migration, a
codec, a merge, a cipher — and `HostSecret.TagIds` has merged per tag so two
people tagging one host both keep theirs. Nothing drew a chip. The tags a
client could store were ones nothing here could see.

Chips on host rows, both heads, from names resolved through the tag list rather
than ids: a tag that does not resolve is left out rather than drawn, because it
means the tag was deleted elsewhere or belongs to a vault this session cannot
read, and a host with one chip fewer is the honest answer where a host wearing
a GUID is not. The id stays on the host, so the chip comes back if the tag does.

The picker is chips that toggle, matching the chips on the row behind it. A
list of names to tick would make the user match an entry to a chip they can see
two inches away. The box under it creates a tag and puts it on straight away,
because that is when a tag is usually wanted — while tagging a host and finding
it does not exist yet. Unlike every other field in that editor it writes to the
keychain immediately, since a host can only name an id that exists; cancelling
therefore leaves the tag behind, which is honest rather than hidden. A name
that already exists is used rather than repeated: two tags called "staging" are
storable and must stay storable, because two people creating one offline is how
it happens, but typing it into a box beside a chip of the same name is a slip.

Renaming and deleting needed a home, or the picker fills with names nobody uses
and never empties. That home is a TAGS category on the keychain screen, where
every other item kind is managed — and renaming is the whole reason a tag is an
item rather than a string repeated inside twenty payloads: it is one write, and
no host is touched. The delete confirmation counts the hosts wearing it, which
is the difference between a tidy-up and losing a filter somebody relies on.

The desktop host editor now scrolls, and that is not a tidy-up. A picker's
height is a chip per tag in the keychain, wrapped, so somebody with fifteen
tags has an editor half again as tall as somebody with three; no fixed height
holds that, and trimming other fields to buy room only moves the failure to
whoever has sixteen. The layout suite caught it the moment its seeder grew tags
— which is why the seeder now creates ten rather than three, enough to drive
the pane onto its cap so the capped shape is what gets measured rather than one
no real keychain produces. The cost is named where it is paid: the harness
skips anything inside a ScrollViewer, so from here it certifies that pane fits
the column rather than that every field in it does.

Two smaller things fell out. Five buttons overflowed the keychain header by a
few pixels, so GENERATE lost the word KEY — its tooltip carries what the word
did. And TotalItemCount had been counting keys and credentials while ALL showed
four kinds; it counts all five now, because a number under a chip that
disagrees with the rows it opens is worse than no number.

An adversarial review of this change found two defects it had introduced, both
green against the full suite. NewTag filed into the "new items go to" picker
while the tag list only ever holds the active vault's — so with a team vault
selected a tag would be created, queued for push, reported as added, and then
invisible, with no row, no count, no picker entry and nothing able to rename or
delete it, because there is no active-vault switcher to go and find it with.
The comment on the host editor's own create path states that exact rule; this
was the one place that broke it, and NewObjectStore, whose list is likewise
active-vault-only, already ignored the picker. And the tag editor was the only
one of five that did not disarm a pending deletion when it opened, so arming a
key's deletion and then pressing + TAG left a live DELETE for an item the user
was no longer looking at, directly above the boxes they were typing into. Both
are fixed, both have a test, and the first was checked against the broken
version before being kept.

The same review caught a doc comment that had been inserted between
SnippetRowViewModel's summary and its declaration, silently taking it over.

Verified by the whole suite on a clean build: 1413 tests over nineteen
projects, none failing. Both heads build. The rectangles the layout suite
cannot reach are phase 9 of docs/manual-checks.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:42:43 +02:00
jaap-janandClaude Opus 5 4925dcf179 Give the phone the + it was promised, and the editors behind it
Steps 5 and 6 of docs/adding-hosts-on-the-phone.md, which finishes it. The
phone can now put things in a keychain rather than only read one.

The floating + arrives three designs after it was first asked for. It was
refused twice on the honest grounds that it would open an editor this head had
not got — and that stopped being true when steps 1 to 4 landed. It raises a
sheet rather than a form, because "add" on this screen has been two operations
since groups existed: a host, or a group to file hosts under.

Both editors are cards in the list's own row, swapped for the list rather than
stacked over it, following SnippetsScreen and for the reason written there: a
form on top of the list hides what it is about. There is still no dialog and no
editor screen anywhere on this head. The port and username boxes may be left
empty, and the greyed text inside each is what the host will actually use —
following the group picker as it moves, so the form says what leaving a box
blank will do rather than making it a guess.

The back gesture gains a guard above the switch rather than a case inside it.
An editor is not a screen and has no entry there, and it is strictly nearer:
the sheet sits over the editor's own screen, so back lowers whatever is topmost
before it considers moving between screens.

A group editor needs a way in, and the phone draws no groups panel — so the
heading in the host list gains a pencil. A + that adds groups with no way to
correct one is the same strange thing to ship as one that adds hosts with no
way to correct one, and worse: a group's defaults are what every host beneath
it falls back to.

Scouting step 6 turned up a bug worth more than the prose it was looking for.
ReloadGroupsAsync read the active vault alone, which was a cosmetic limitation
while a group carried only a name — a teammate's host showed under UNGROUPED
and nothing else was lost. Since a group began lending a port, a username and a
binding, the same omission silently dropped all three: that host would dial 22
as nobody while the machine is on 2222 as deploy, with nothing on screen saying
why. The resolution map now spans every readable vault. The editable list stays
narrow, because a row shown across vaults needs a vault id for rename and
delete and two vaults may hold groups with one name — but the map needs none of
that, since it is only ever asked what an id says.

An adversarial review of this change found something I had written a comment
denying. The + was a child of the outer Panel rather than of the list's row, so
it anchored to the bottom of the screen and sat squarely over the new EDIT
button and the lower edge of CONNECT — and because a Panel hit-tests its
last-declared child first, tapping the right end of EDIT would have raised the
add sheet. The comment beside it claimed it was in the list's row precisely so
that could not happen, and manual-checks 8.1 names that exact failure. It is in
Grid.Row 2 now. The same pass caught a connect bar that was disabled rather
than hidden while its own comment said hidden, a scrim that would have flashed
its default pressed chrome across the screen, a fab comment claiming an accent
fill no other button had when Button.primary has it, and a back-gesture remark
naming two view-model properties that do not exist anywhere in the repo.

That last one came from this plan, which invented IsVaultsSurface and
IsConnectionsSurface, and pointed step 6 at a ConnectionsScreen.axaml that has
never existed. Both are corrected in the plan rather than quietly worked
around, along with a fourth "groups are flat" site in README.md that step 1
missed.

The phone's rectangles are not measurable and structurally never will be: the
layout suite is net10.0, the Android head is net10.0-android, and Avalonia's
application is a one-shot process global — so a second head cannot share the
process even if the reference were possible. Everything the sheet and both
editors bind to is shared and is tested headlessly here; the pixels go to
docs/manual-checks.md phase 8, nine checks, which is where this project already
sends what it cannot assert.

Tags remain the one thing asked for that did not ship. Tag is a full item kind
and TagIds merges, encodes and resolves, but no screen draws a chip or offers
to add one, so the tags a client can store are ones nothing here can see. Both
editors carry the set through a save untouched so a client that can set them
does not lose them. Recorded as half-shipped in docs/design-import-gaps.md
rather than quietly left out.

Verified by the whole suite: 1402 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display — phase 8 is what that costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:30:00 +02:00
jaap-janandClaude Opus 5 b7335743d9 Make a host take what its group lends it, everywhere it is read
Step 4 of docs/adding-hosts-on-the-phone.md. The domain could resolve a host
against its groups; nothing asked it to. This is the wiring, and it is mostly
one change repeated: read the resolved host, not the stored one.

TryBuildAuthentication and TryBuildConnectionRequest now take the resolved
value beside the stored one, which is where group context was being lost. It
is the only authentication resolution in the product — both heads and both
transports come through it — so a host inheriting its binding would otherwise
have been offered a password prompt on every screen at once. The
credential-username fallback becomes three levels, and Complete still refuses
an empty username, but now only after the chain has been walked; refusing
before it would refuse exactly the hosts inheritance exists to serve.

HostRowViewModel carries its ResolvedHost, resolved once when the list is
built. Address, Authentication and Dialled read it, so a row cannot disagree
with itself about what it dials — and MainWindowViewModel.Rank searches
Address, so a host inheriting 2222 that displayed 22 would have been
unfindable by the port it actually answers on.

HostsBoundTo counts over the resolved binding, which is the difference between
a warning and a silence: a key bound once on a group and inherited by twenty
hosts named nobody, would have been deleted, and would then have refused all
twenty at connect time.

HostFields.From is answered by a refusal rather than by threading a group list
through the sync engine. A relay host may not inherit its port. The reason is
stronger than the convenience: a plaintext column is a derived duplicate the
client supplies when it pushes *this* host, so an inherited port would make it
depend on another item — editing a group would change what the relay dials for
every host beneath it, except that nothing re-pushes those hosts, so the server
would keep dialling the old port until each was next touched for some unrelated
reason. A stale wire on the relay path connects the user to the wrong service.

The editor distinguishes unset from explicit in both directions. An empty port
box means "take the group's" and shows what that will be as a placeholder,
following the group picker as it moves — a pre-filled 2222 would have been
indistinguishable from one the user typed, and saving would have pinned it. The
authentication picker gains a fourth entry, offered only to a host in a group,
because for an ungrouped host it would behave exactly like the first.

Which found a real defect while the tests were being written. Filing an
ungrouped host into a group silently pinned it to a typed password: the picker
had no "Inherit" entry when it opened, so it sat on "Password (ask each time)",
and saving wrote that as a decision — the host would have been pinned to a
prompt nobody asked for and the group's key would never have reached it. Two
guards now: the picker is rebuilt when the group changes, and BuildHost writes
AsksForPassword only for a host that had the alternative on offer.

The group editor is here too, and the plan never assigned it a step. Without it
no group can carry a default, so every line above would have been unreachable.
It grows a parent picker that leaves out the group itself and everything
beneath it — a courtesy rather than the guarantee, since a cycle assembled from
two offline re-parents was never offered that list — and three defaults beside
the name, each of which may be left empty because "lend nothing" is an answer.

Tags are stored and not editable. TagIds merges, encodes and resolves; no
screen can set one yet, and the editor carries the set through a save untouched
so a client that can set them does not lose them to somebody editing a port.

Eight new tests, and they dial. That is the point of them: a resolved value
that never reaches SshConnectionRequest is a label, and every one of these
failures would be silent — a host connecting to the wrong port, or being asked
for a password it does not need, with nothing on screen admitting it.

Verified by the whole suite: 1390 tests over nineteen projects, none failing.
Both heads build. Nothing on the phone has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:41:28 +02:00
jaap-janandClaude Opus 5 8c04ba60b0 Build the three things the phone's + needs, before the + exists
Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing
on either head has changed, which is deliberate — the plan orders these first
because everything the editors will bind to has to exist and be merge-safe
before a screen can offer it.

HostGroupSecret gains a parent and four defaults, and the codec gains the
version rule it never had. It stamped CurrentSchemaVersion unconditionally,
which was harmless with one field and one version and stops being harmless
here: upgrading one machine and renaming any group would have made that group
uneditable on every machine still on the old build. It now emits the lowest
version that loses nothing, so a flat group with no defaults still encodes at
version 1, byte for byte, pinned against a literal.

Tags become a real item over the reserved slot. Secret, codec, merge, cipher,
repository, both registries, the EF entity and a generated AddTagItem
migration. TagCipher names AadResourceType.Tag as a constant rather than
casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum
and 5 there is Credential — a cast would seal every tag under the resource
type for a password, encrypt and decrypt perfectly on the machine that wrote
it, and only fail when another implementation refused the item, by which time
the AAD is frozen into stored ciphertext. HostTag stays reserved and unused:
the one thing the join buys over a set on the host is bought instead by
merging TagIds per id.

HostSecret grows TagIds and Port goes nullable, which is the change with the
widest blast radius and the only one that loses an item rather than locking
one. A host with no port of its own omits the property, an older build reads
int Port as 0, and TryValidate refuses it — unreadable rather than read-only.
That cost is confined to hosts which actually inherit, because the version is
a maximum over the fields present; the alternative, writing 22 into every
host, is the lie inheritance exists to stop telling.

One decision the plan did not specify. "Three states where there were two" is
four — key, credential, typed password, or the group's answer — and two
nullable ids carry three. Naming neither id now means inherit, so
AsksForPassword says "a typed password even under a group that lends a key"
out loud. Only true is ever written and a decoded false folds back to null, so
a host that never touched it encodes as it always did. Nothing already stored
changed meaning: no group could lend a binding before this build, so every
existing host resolves exactly as it did.

HostInheritance is the resolver, and its visited set is load-bearing rather
than defensive. Two clients can each re-parent A under B and B under A while
offline; the merge sees one item against one item and the server sees
ciphertext, so nothing upstream can refuse the pair. With inheritance the
chain is walked at connect time, so an unguarded cycle is not an undrawable
sidebar — it is a shell that never opens. Stopping at the first repeat
degrades it to a group that reads as a root, and clearing the parent is the
repair.

A tag set turns out to be the one field on a host that can never ask the user
anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so
the both-sides-moved-differently branch of the keyed merge is unreachable —
asserted over the whole eight-row matrix. The conflict loop is kept anyway,
because that proof is one edit from ceasing to hold and what it would cause is
a discarded tag nothing records.

Three guard tests failed by design and were fixed rather than relaxed: the
ordered pull filter, the AAD pinning table, and the server's refusal of a
plaintext parent — that last one survives with its reason rewritten, because
the refusal now means "the parent is not the server's to hold" rather than
"there is no such thing as a parent". The prose that said groups are flat is
rewritten in all four places it appeared, not deleted.

The five view-model sites that read Port directly now go through the resolver,
which is a down payment on step 4 rather than the whole of it. HostFields.From
still emits the stored port, and that is the one remaining place where an
unresolved read would be a wrong wire rather than a wrong label.

Verified by the whole suite: 1382 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display, because nothing on a display has
changed yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:21:02 +02:00
jaap-jan c9eca96ce7 Merge pull request 'Write down how the phone will add hosts, before it adds any' (#2) from add-hosts-on-the-phone into main
ci / build and test (push) Successful in 1m4s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 33s
Reviewed-on: DodoTech/DodoSSH#2
2026-08-02 20:08:49 +00:00
jaap-janandClaude Opus 5 9629b7d938 Write down how the phone will add hosts, before it adds any
ci / build and test (pull_request) Successful in 1m12s
ci / android head (pull_request) Failing after 5s
ci / api image (pull_request) Successful in 3s
The + button the design has asked for twice needs three things that do not
exist: a tag, a group's parent, and a group's defaults. Two of them are
refused on the record — HostGroupSecret argues groups are flat, and the
theme argues an unused style is a claim the control exists.

So the plan goes in first, with the decisions and the reasons. Tags become a
real item over the reserved slot, a host names them, and membership merges
through ThreeWayMerge.Map rather than as a whole value, so two people tagging
one host both keep theirs — which is what HostTag was going to buy. Group
defaults inherit rather than copy, shown as the field's placeholder, which is
what makes editing a group afterwards mean anything. A parent arrives with a
visited-set walk, because with inheritance a cycle is no longer an undrawable
sidebar — it is a shell that never opens.

Also written down: that Port has to go nullable and everything that touches,
the byte pin it will trip, and the schema-version branch whose absence would
make an inheriting host unreadable rather than read-only on every client that
has not been upgraded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 22:07:30 +02:00
jaap-janandClaude Opus 5 359087f1ce Give the tabs their hover back, and the plus the shape its comment claims
ci / build and test (push) Successful in 1m12s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 27s
Two things v2 broke in the last commit, both found by reading resolved brushes rather than markup, and both
the same mistake: Avalonia has no specificity, so the later declaration wins, and a rule that restated the
base instead of excepting from it went in below the rules it was supposed to be underneath.

Making a tab a pill gave it a Background of its own, which the flat tab it replaced never had. That one
detail moved where the hover has to live. `Button.flat:pointerover` is declared far above and had been
supplying it; the moment `Button.tab`'s template rule set a Background, it won, and every tab in the strip
stopped answering the pointer. Silently — a tab that no longer lights is not a crash and not a layout
change, and nothing in a suite that measures heights and reachability can see it.

The `+` lost more than that. It sits below as an exception — no outline, because it is not one of the
things being chosen between — and a `.tab` rule declared after it was overriding the exception itself. It
drew as a filled, outlined pill identical to a tab, contradicting the comment directly above it.

So the base pill and its hover come first now and the exceptions follow, which is the order the rest of
this file already uses and the order the Border.rowmark note further down was written about. The `+` clears
the fill as well as the border, because an exception to a rule that sets both has to say both.

The titlebar's search box had the same shape of error in geometry rather than colour. The design draws it
at exactly 380 and centred, and stating that as a Width on the inner Border is what made it wrong: the
button around it is free to shrink when the account name or the vault chip beside it is long, and a Border
that will not shrink with it arranges outside its own parent — over the name on one side and over the
window buttons on the other. MaxWidth on a stretching button gives the same 380 whenever there is room and
gives way when there is not.

The regression test is the point of this commit rather than an afterthought. It is the only test in that
suite that reads a brush, and the gap it fills is exactly the one these two went through: everything else
measures rectangles. It hovers a tab through the real input path and asserts the fill changes, then asserts
the `+` is neither filled like a tab nor outlined like one. Checked against the broken ordering before
being kept — it fails there and passes here, which is the only thing that makes a regression test worth
committing.

Verified by the whole suite: 1310 tests over nineteen projects, none failing, the layout suite now 70
cases. Both heads build. Still nothing seen on a display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 19:54:37 +02:00
jaap-janandClaude Opus 5 3627021420 Give the desktop the second design too, and the window the size it now needs
The desktop v2 design is the other half of the one the phone took last commit, and this is its chrome: a
190-pixel labelled sidebar where the 54-pixel icon rail was, a titlebar with the search box centred in it,
and session tabs drawn as pills. The palette was already here — it is shared, and moved when the phone's
did — so what this changes is shape rather than colour.

**The window's minimum grew, and by exactly what v2 added.** The sidebar is 136 wider and the chrome 14
taller, so 880x560 became 1016x574. That is not a round number somebody liked: it leaves every screen the
same 826x464 it was designed against, which is the arithmetic the layout suite is built on. Four of the
tables stop fitting at 690 wide, so widening the sidebar and leaving the window alone would have broken
them somewhere no test was looking. LayoutHarness carries the new constants and the suite still passes at
the minimum, which is the whole reason it exists.

The rail's five-character abbreviations are gone with the width that caused them — PINS and SNIPS are Pins
and Snippets again — and each row gains a glyph and a count. A count is drawn only where one is real, so
SFTP, Logs and Preferences show nothing rather than a zero: a transfer queue's depth is not how many files
a screen holds, and a log has no total until it is read. The count beside Pins is the vault's own, not the
Pins screen's VisiblePins, which is the filtered list and would have made the sidebar count whatever
somebody had just typed into a filter box on another screen. Teams has no count for a related reason: they
are read from the server when that screen is opened rather than on unlock, so a number there would read 0
until somebody had already been to look.

One colour moved with it, finishing what the repalette started: the live-session summaries on the unlock and
sign-out cards were Info, so the two heads disagreed about a fact the phone paints green. They match again.

**Buckets became a destination rather than a mode**, which is what the design draws and what the phone
already does. The HOST / BUCKET pair inside the files screen is gone; ShellScreen.Buckets draws the same
TransfersScreen with the other picker, and the sidebar entry is what sets it. That also settles an old
disagreement rather than merely moving it: TotalItemCount is keys plus passwords and excludes buckets, so
the number beside the keychain used to disagree with the list under it, and now counts what that screen
shows.

There is one session behind both file destinations, so asking for the other kind while something is open is
refused rather than obeyed — and refusing means staying put. An earlier turn of this had it move anyway and
only decline to switch the picker, which put the S3 entry in the sidebar over a screen still listing an
SFTP host: two pieces of chrome disagreeing about where you are, which is worse than the navigation simply
not happening. The message that says so goes to Transfers.Status, which turned out to be drawn in the same
grid cell as the connected chip — survivable while it was mostly read before connecting, and not once a
refusal reports itself there. It has its own column now.

The design has nine entries' worth of screens and draws five. Pins, Teams, Import and Preferences are
built, working screens, so they keep their entries — the sidebar is labelled now and has the room, and
dropping an entry would have stranded a screen rather than simplified anything. The Team vault card the
design pins to the foot is not drawn: it is a second route to a screen already in the list, carrying a seat
count nothing here produces.

**The status bar survives the design that deletes it**, cut down to one thing. Two of the three facts it
carried moved into the titlebar with v2 — the sync word is beside its dot and the shortcut hint is inside
the box that uses it — so those are gone from it rather than printed twice. The third is Vault.Status, the
only channel this application has for saying a save failed or a merge picked a winner. The design is a
mock-up of an afternoon that goes well and has nowhere to put a sentence like that; dropping the bar would
have meant dropping the sentence or repeating it on nine screens.

What v2 draws and this does not is in docs/design-import-gaps.md, and it is the same list as the phone's
for the same reasons: the forwarding screen and both its chips, the host detail's fingerprint, tags and
last-session cards, the keychain's rotate button, the logs' FOLLOW pill and severity filters, and the
session footer's latency. The terminal is not inset behind a rounded frame either — it is a native child
window that composites above everything Avalonia paints, so the frame would clip nothing, which is the same
answer the phone gave.

**The light theme is not built.** Its accent is #6D5AE6, a different hue rather than a tint of the dark
one, so it needs every colour doubled, a variant to switch on, the renderer's own page switching with it,
and contrast checked twice. That is a piece of work rather than a setting, and it is separable from the
layout — which is why this commit is the layout.

The screens themselves are restyled through the shared vocabulary rather than rebuilt: corner radii,
chips, cards and the accent's ink, all in App.axaml, so every screen moves at once. Their layouts are left
alone deliberately. The design draws read-only detail panes and these screens carry the editors and forms
it has no equivalent of, so replacing a layout with the mock-up's would have lost the half that is
actually used.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the 68 layout cases
that stand up real Avalonia and measure every screen at the new minimum. Both heads build. Not run on a
machine with a display — see docs/manual-checks.md for what wants looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 19:29:21 +02:00
jaap-janandClaude Opus 5 5593f337b6 Give the phone the second design, and both heads the palette it arrives with
The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts,
Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the
last. The first design's four had nothing behind them, which is what made a hub worth building.

The palette moved from green-black to blue-black, and it moved in the shared project because that is
where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning
rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so
Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were
the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which
finally separates them: Live is that green and nothing merely interactive may use it. The accent is
also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is
a row of things that all look like the primary action.

A palette is not one file, which is the part worth knowing before the next one. Nine hex literals
lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state,
two scrims, the window background Android paints before Avalonia has a frame, and the launcher
vector. The two C# sites now resolve from the dictionary by name rather than restating it. The
renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and
terminal.js keep hand-copied values and say so at both sites.

ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over
one TransfersViewModel differing only in which picker they offer, and the kind is set by the button
that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers
force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket
already open. It refuses to change kind while a session is live, because there is one session behind
both destinations and switching under it would title a screen S3 while it listed an SFTP host.

What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here
forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub
naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd
5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a
fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet
run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are
written once at close and read when the screen opens, and the severity filter, which has nothing to
count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and
lifecycle. And the + on HOSTS, which would open a host editor this head has not got.

SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local
pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at
SpecialFolder.UserProfile, which on Android is the application's own private directory. A download
would have reported success and left the file where the person who asked for it cannot open it, which
is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is
not drawn either, since nothing here can put anything in it. Both return with the document picker.
The foreground service still counts zero transfers, and the reason moved rather than went away.

Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox
ItemTemplate swallows the pointer press before the list sees it, so the files listing selected
nothing and every command reading the selection did nothing — the row is a Border now and the
phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from
walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched
SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was
breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no
handler for the system back gesture, so back left the application from a log screen. And the tab's
close cross had shrunk to a 30x32 target flush against the select target, which is the one control
here that ends a shell with no confirmation and no undo.

Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which
is still there. Only at launch: a lock the user asked for is not answered with an immediate request
to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody
asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a
prompt that came back after being dismissed would be a modal you cannot get out of to type into it.

Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with
Accent, which was blue against green and is now two steps of one blue; an executable is Live now.
And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification
time — blank now, for the reason a directory's size is blank.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout
suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on
a device — nothing in this head ever has been; see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 18:23:53 +02:00
jaap-janandClaude Opus 5 c00e5dbc5c Let the terminal's text be made bigger, and remember how big
ci / build and test (push) Successful in 1m12s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 24s
Taking pinch-zoom off the phone left nothing in its place, and there was nothing on the desktop
either. This is the replacement, and it is deliberately not the thing that was removed: zoom scales
what has already been drawn, so the remote goes on wrapping to a width that is no longer on screen.
Changing the font size refits the grid and reports the new column count, so the far end is told it
has fewer columns. That round trip is the feature.

The size is one number, owned by the shell. It has to be, for two reasons that pull the same way: it
must survive a relaunch, and it must be reachable from a phone that has no Ctrl key to press. So the
page asks and the host decides — a signed step over a new client opcode, answered with a size over a
new server opcode. The phone's buttons and the desktop's chords arrive at the same place, and a size
set by either is the size both remember.

Stored in settings.json beside the cache rather than in it, and that is not laziness about a
migration. The cache is encrypted and unreadable until a vault is unlocked, and the first terminal of
a locked launch needs the size already. Nothing secret may go in that file; ClientSettings says so
out loud, because the next person to add a preference is the one who needs to read it.

Where it is reachable from differs per head, and only here. The phone gets A− and A+ on the
connection line — not in the accessory row, which scrolls, and a control that fixes unreadable text
must never be the thing that is off-screen. The desktop gets the three chords every terminal
emulator has, answered by the page while a terminal has focus and by the window when it does not,
plus a row in preferences that shows the current value and names the chords rather than replacing
them. Someone whose terminal is too small to read is not in a position to go looking.

Clamped 8 to 32. Below eight a monospace grid stops being legible and becomes a texture, and every
column of it is still a column the remote is being told exists; above thirty-two a phone in portrait
has too few columns to hold a prompt. The buttons disable at the ends rather than accepting presses
that do nothing, which on a terminal reads as the application having stopped responding.

The preferences screen's header comment claimed none of the design's terminal settings could be
saved, and listed the three things that were missing to make one work. All three now exist, so it
says which one is real and why the other five still are not.

Verified with the protocol suite — including that the step byte round-trips signed, since read
unsigned a step down arrives as 255 and clamps to the largest font, making "smaller" do the most
dramatic available version of "larger" — a data-plane test that the chord is heard with no session
registered, and five shell tests: the default matches the renderer's, both clamps hold, reset works,
and a size chosen in one shell is there in a second one over the same profile directory. Layout
suite and both heads build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:15:11 +02:00
jaap-janandClaude Opus 5 5cb9ffaf9d Stop the phone's terminal being a page you can pinch
ci / build and test (push) Successful in 1m3s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 36s
The terminal on Android could be pinch-zoomed and double-tap zoomed, which a terminal must not
be: the grid is sized to the window by the fit addon and the column count is told to the remote,
so zooming makes the visible width disagree with what the far end is wrapping to, and puts the
cell under your thumb somewhere other than where you tapped.

Three things, and the first is the one that made it look worst. The page had no viewport meta tag
at all. An Android WebView with no viewport lays out at a notional 980 CSS pixels and scales the
result down to fit, so a terminal built to fill the window was drawn small and then offered as
something to zoom around in. width=device-width makes one CSS pixel one layout pixel, which is
what the fit addon has been assuming all along and what a desktop WebView gives without being
asked.

Second, the gestures. touch-action: pan-y keeps the only one a terminal wants — dragging the
scrollback — and refuses pinch-zoom and the double-tap zoom that fired on every attempt to place
a cursor. text-size-adjust stops Android's own font inflation, which resizes text it judges too
small without telling the page and leaves the characters no longer matching the grid that was
measured.

Third, the knob that actually disables zoom: BuiltInZoomControls on the WebView. user-scalable=no
is in the viewport tag for completeness and does nothing on its own — Blink has ignored it since
Chrome 48 and WebView follows Blink. That is worth knowing before somebody removes the C# and
trusts the meta tag.

The page and stylesheet are shared with the desktop head, deliberately, and none of it costs
anything there: a desktop WebView already lays out at device width, and a Windows touchscreen
should not be pinch-zooming a terminal either.

This does not settle whether the phone should keep a browser-based terminal at all, which is the
question actually asked. It does remove the reason it was asked, and every complaint about this
renderer so far has turned out to be configuration rather than the approach.

Verified by building the head in Debug and Release and by the desktop layout suite, which draws
the same assets. Not verified on a device — nothing in this head is; see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:45:11 +02:00
jaap-janandClaude Opus 5 52596aac76 Ask the Linux WebView for the one mode it can draw in this window
ci / build and test (push) Successful in 1m12s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 22s
The terminal renders nothing on Linux, and does it in the most misleading way available: the
page loads, the scripts run, the renderer connects, InvokeScript answers. Everything works
except the pixels, so it reads as a broken terminal rather than as a host with nowhere to
paint.

Measured on Fedora 44 with Avalonia.Controls.WebView 12.0.1, by a spike that hosts a
NativeWebView and reads AdapterInfo. The backend is WebKitGTK 2.52.5 — not the WPE one this
repository's platform notes predicted, and Fedora packages no WPE WebKit at all, so that path
was never going to be the answer here. In its default mode the adapter reports
SupportedScenarios = NativeDialog: a window of its own, and nothing that can be hosted in
place. Identical under X11 and Wayland, so it is the adapter's answer rather than a session
problem.

Setting ExperimentalOffscreen on the GTK environment arguments changes the same adapter's
answer to OffscreenRenderer — the compositor-drawn mode, which is what the
NativeWebViewCompositorHost mentioned in those same notes exists to host. MainWindow now sets
it as the environment is settled. Windows and macOS are untouched by construction rather than
by an OS check: the argument is a GTK type there and the handler does nothing.

The platform notes carried this as "unproven, and still the largest risk in the plan". They
carry the measurement now, including the part that is still unproven and the reason the spike
could not settle it.

What is NOT verified is that it now paints. An XWayland root capture is black under a Wayland
compositor and RenderTargetBitmap does not capture a compositor surface, so both ways of
looking at it from here failed. It needs eyes on a running client, and if the terminal is
still blank the next question is whether it takes input at all — that separates "not drawing"
from "not hosted".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:35:19 +02:00
jaap-janandClaude Opus 5 a0d53b0c9d Give the phone its screens back by not stealing a data context
Signing in on the phone reached an unlocked shell showing PREFERENCES over the middle of the
screen, with a bottom bar whose four buttons did nothing. The buttons were fine. Every one
of them changed the shell's screen exactly as asked, and nothing moved, because an opaque
panel was sitting on top of the whole page area and never came down.

PendingScreen set DataContext = this in its constructor, so that Heading and Detail could be
written as plain bindings inside its own XAML. That is not a private arrangement: a binding
the parent writes ON one of these — IsVisible="{Binding IsPreferencesShowing}" in
PhoneShell — resolves against this control's data context, which was no longer the shell.
The binding looked for a shell property on a PendingScreen, found nothing, and left
IsVisible at its default. Its default is true. So the panel that says "this screen is not
built yet" was permanently visible, last in the Panel and therefore on top of the host list
and the keychain both — and the terminal underneath them.

The two properties are read with $parent now and the control inherits its context like every
other screen. There is no x:DataType on it any more either, so a plain binding here is a
compile error rather than a silently missing screen.

The desktop head has the other half of this lesson written down already: MainWindow gives the
terminal's IsVisible a FallbackValue precisely because an unresolved visibility binding does
not hide anything, it shows everything. That note was about the previewer. This is what it
looks like at runtime.

Verified by reproducing the mechanism rather than by reasoning about it: a control that owns
its data context ignores a parent's IsVisible binding and stays visible; one that inherits
obeys it. The head builds in Debug and Release. Nothing has been run on a device, as ever —
see docs/android-port.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:35:04 +02:00
jaap-janandClaude Opus 5 ffab2be22a Drop the formatting step, which spent minutes agreeing with the build
ci / build and test (push) Successful in 1m11s
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 43s
`dotnet format --verify-no-changes` re-analysed the whole solution before the build did, to
reach a verdict the build reaches on its own: IDE0055 is an error in .editorconfig,
EnforceCodeStyleInBuild is on and warnings are errors, so a misformatted file fails the
build step. What the separate step bought was hearing about it a few minutes earlier, and
it charged those minutes on every run.

Checked rather than assumed, because the whole justification rests on it: appending a
badly-spaced member to a source file produces three `error IDE0055` lines and a failed
build with no format step in sight.

Three places said the old arrangement out loud and would now be wrong on their own — the
comment on the IDE0055 line, the conventions list in the README, and a note in
platform-flags telling people to run dotnet format before pushing or CI would fail them.
They say the build enforces it now. dotnet format is still how to fix what the build
complains about; it just no longer gates anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:43:32 +02:00
jaap-janandClaude Opus 5 ddf0dd6a2b Stop the transfer tests depending on which thread ran them first
Every test in TransferQueueingTests failed on the Linux runner with "The calling thread
cannot access this object because a different thread owns it", and none of them had
anything to do with the commits in that run. The class drained its rows through
Dispatcher.UIThread.RunJobs(). That dispatcher is process-wide and belongs to whichever
thread touched it first, and xunit runs each test class as its own parallel collection — so
the moment a runner scheduled another class onto that thread ahead of this one, all nine
died inside DispatcherOperation.Execute having asserted nothing about transfers at all. It
passes locally and fails on a machine that schedules differently, which is the whole of why
this took a CI run to find.

TransfersViewModel now takes the poster it marshals through, defaulting to
Dispatcher.UIThread.Post — the seam VaultViewModel's clipboard already is, for the same
reason: a view model that reaches a process-wide UI object directly makes every test of it
depend on a thread it does not choose. No head passes the parameter, so nothing about the
running application changes.

The test supplies a queue of its own and drains it, which is the same shape the dispatcher
gave it. A poster that ran the action inline was tried first and is wrong: the transfer
queue raises Changed from its pump thread as well as from the call that enqueued, so inline
execution has a background thread adding rows to an ObservableCollection while the test
reads it — it passed once and then failed a different test on the next run. Draining keeps
every mutation on the thread doing the asserting, which is the one thing the dispatcher was
providing that was worth keeping.

One test added for the property that broke: queueing is reachable from any thread and must
not care which. The class as a whole guards the seam — remove it and nothing drains, so
every assertion about a row fails.

Verified by reproducing the failure first: a throwaway probe that touched the dispatcher on
one thread and posted and drained on another produced exactly the CI message. Then six
consecutive Release runs of the app suite, all green, plus the layout suite, which builds a
TransfersViewModel of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:43:21 +02:00
jaap-janandClaude Opus 5 093f3904c1 Apply pending migrations at startup instead of asking for a second command
ci / build and test (push) Failing after 1m48s
ci / api image (push) Skipped
ci / android head (push) Failing after 5s
The API deliberately never migrated: it failed readiness while a migration was pending and
named it, and a separate step applied them. That is the right split for a deployment with a
release pipeline and the wrong one for a self-hosted server, where it means an image that
boots, refuses traffic, and waits for somebody to know that dotnet ef exists. The schema and
the code that expects it ship in the same image, so the image is where the two are
reconciled now.

Before RunAsync rather than in the background. A migration racing the first requests would
let them through against a half-applied schema, and the first authenticated request is the
one that provisions accounts. Failing to migrate therefore fails to start, which is the
loudest signal available and the one an orchestrator already acts on.

Concurrent starts take a Postgres advisory lock first. Without it two replicas rolled out
together read the same empty history table, both apply the same migration, and the second
dies on an object that already exists — a crash loop on the day of a schema change, which
is the worst day to have one. The lock is held on a connection of its own because EF opens
and closes one per command, and a session lock belongs to the connection that took it.

The exception is a database that does not exist yet: there is nothing to hold a lock in, so
that path migrates without one and says so. Two instances creating it at once still
converges — one wins, the other restarts into the ordinary locked path — and refusing to
start would leave a fresh deployment stuck on the step this removes.

Database:AutoMigrate turns it off for the deployments that own their schema: a migrator job,
a rollout where new code must run against the old schema first, or a database user denied
DDL. With it off the behaviour is exactly what it was, and the health check now explains
which of the two situations a pending migration means.

Verified against a throwaway PostgreSQL container: an empty database gets all seven
migrations applied before the port opens, the tables land in the dodo schema, and a second
start logs the schema up to date and serves. The API suite passes, which exercises the
startup path once per assembly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:12:22 +02:00
jaap-janandClaude Opus 5 73c7e2a1e3 Point a release build at the hosted server, and a debug build at the clone
The shipped default was http://localhost:5233, which is the address the API serves under
dotnet run and a machine an installed application is not running. Somebody who installs a
release and accepts the field unread is signing in to nothing.

Two defaults now, because the two audiences never overlap. A release build offers
https://ssh.dodotech.cloud, so a first launch needs no address typed at all. A debug build
keeps the loopback address, and that half matters as much: shipping the hosted address into
a clone would point every development launch at production, and sign-in is the call that
provisions an account there.

The remark carries the reason the schemes differ, since the pair now looks like an
oversight rather than the deliberate thing it is — the API's first launch profile is
plaintext on 5233, and an HTTPS client meeting a plaintext port reports a TLS failure that
reads like a certificate problem.

The test spells out both branches rather than asserting the constant, which would pass
however it were edited. What it is really guarding is that a release never ships a
developer's loopback address and a debug build never points a clone at production, and it
can only guard those by naming them.

Verified by running the shell suite in Debug and in Release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:12:00 +02:00
jaap-janandClaude Opus 5 39b7e5620f Say which half of signing in is happening, and which half failed
A sign-in against a production Keycloak was reported as the shell hanging on "Opening your
browser to sign in…" and then, some time later, saying "The server returned 500". Both
halves of that are the message's fault. The browser flow had already succeeded — the
provider authenticated the user, the code came back, the tokens were exchanged — and what
was actually happening was a round trip to the DodoSSH server for the account. The screen
went on describing a browser nobody was waiting for.

The status now moves when the browser half ends, so the wait that follows is attributed to
the server being asked rather than to the browser that has already answered.

The failure gets the same treatment. "The server returned 500" is the API client's phrase
for any server it talks to, and read underneath a sign-in button it is naturally taken as
the sign-in having failed — which sends somebody to their identity provider's logs to find
out why a thing that worked did not work. It now says signing in succeeded, names the host
that failed afterwards, and says the reason is in that server's logs, because this side
cannot know more than that.

Nothing here fixes the 500. It changes which of the two servers the next person goes and
looks at, which was the actual cost of the old message.

Verified against the shell suite, including the case that asserts a failed command leaves
the window enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:11:38 +02:00
jaap-janandClaude Opus 5 1db8bed872 Let a cancelled sign-in end the sign-in rather than the timeout
Backing out of the login page on Android left the shell showing "Opening your browser to
sign in…" with the button disabled for five minutes. Nothing was wrong except that nobody
told it: the redirect callback only ever completed when an intent arrived, so a user who
pressed back was waiting on OidcClient's browser timeout to expire before the flow failed
and the button came back.

There is no cancel event to subscribe to on this platform. Pressing back, dismissing the
browser and closing a provider's error page are indistinguishable from here — the browser
goes away and this application is foreground again with nothing delivered — so being
resumed while a sign-in is still waiting is the signal, and the only one there is. The
launcher records that a browser took the intent, OnResume fails the wait, and the guard
means the resumes that have nothing to do with signing in (a launch, recents, the
keystore's fingerprint prompt) go through untouched.

An exception rather than a cancellation, because OidcClient reads a cancelled wait as its
own timeout expiring and would report five minutes passing to somebody who waited two
seconds. It cannot steal a successful sign-in either: Android delivers the redirect to
OnNewIntent before resuming the activity, so the completion is already settled and the
attempt does nothing.

The enrollment key-binding trip through the browser is covered by the same change, since it
waits on the same callback.

The OnNewIntent remark had been sitting above OnResume, describing a method two below it.
Moved back, since the new remark wanted the space and the old one was wrong where it was.

Verified by building the head in Debug and Release. The behaviour itself is unverified for
the reason docs/android-port.md gives about this whole head: nothing has been run on a
device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:10:37 +02:00
jaap-janandClaude Opus 5 08a820adcf Stop the login step interpolating a comment I wrote in it
ci / build and test (push) Successful in 1m40s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 40s
The secrets were never the problem. The log shows both arriving masked, which is what a
runner does with a value it holds — so the repository secrets were configured correctly the
whole time, and the guidance about Actions Variables was wrong.

What broke was the guard added to diagnose them. Its comment contained an expression
delimiter written out literally to explain what an unset secret renders as, and a shell
comment is not a comment yet at that point: the runner substitutes the whole script before
any shell sees it, so it tried to evaluate an empty expression and failed the step with a
parse error carrying no line number. The step never ran, and push then reached the registry
with nothing to authenticate as — "no basic auth credentials", which looks precisely like
the missing-secret problem the guard was added to rule out.

The comment now describes the delimiter instead of containing one, and warns the next
person, since the failure is invisible to review and to every local check: the file is
valid YAML and the script is valid shell.

Verified with a scan for empty expressions across every run block in the file — one before,
none after — and by running the step's script with credentials set, which passes the guard
and gets a 401 from the real registry. That is the right answer for an invented password,
and it means the endpoint is reachable and the path through this step is sound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:01:06 +02:00
jaap-janandClaude Opus 5 2bc0d4d89f Say which credential is missing instead of letting docker guess
ci / build and test (push) Successful in 1m47s
ci / android head (push) Failing after 5s
ci / api image (push) Failing after 4s
The registry secrets are reportedly not arriving, and the job could not have told anybody
which one or why. An unset secret is not an error anywhere upstream: ${{ }} renders a
missing value as an empty string, so docker gets --username "" and replies with something
about credentials — which reads as the registry rejecting a login rather than as a value
that never left the settings page.

Checked before use now, and reported by length rather than by value. Gitea masks known
secret values in logs, but a mask is only as good as the runner's bookkeeping, and a length
answers the only question actually being asked: did anything arrive at all. The message
names the page to look at, and names the neighbouring one too, since Actions Variables and
Actions Secrets sit next to each other and only one of them is readable through the secrets
context.

This does not fix the credentials. It converts a confusing failure into a specific one, so
the next run distinguishes "the secret is empty here" from "the registry refused these" —
two problems with nothing in common that currently look identical.

Verified by running the step's script with both variables set empty, which is the reported
symptom: it names both, points at the settings page and exits 1 before docker is called.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:51:12 +02:00
jaap-janandClaude Opus 5 a515a35804 Build the image with BuildKit rather than the builder Docker is retiring
ci / build and test (push) Successful in 1m38s
ci / android head (push) Failing after 4s
ci / api image (push) Failing after 21s
"DEPRECATED: The legacy builder is deprecated and will be removed in a future release."
Not a failure — the image was built and the job carried on — but a countdown, and one the
last commit walked straight into: Alpine's docker-cli package does not carry buildx, so
giving the job a working client left it building the old way.

Two packages instead of one now. With the plugin present `docker build` routes through
BuildKit on its own, which also stops the Dockerfile's independent stages being serialised,
so this is slightly faster as well as not deprecated.

buildx is wanted rather than required, and the difference is deliberate. Missing it costs a
warning and a slower build; the image is still correct. So each install branch ends in
`|| true` and the check afterwards reports instead of exiting — a distribution with no
package for it should not be able to turn a release into a red build over a plugin.

The comment above the build step said this job needed "no buildx plugin", which was true
when the build was the only thing being weighed and is not true now. It says what is
actually wanted, and what is still not: no QEMU, no builder instance to create and tear
down, no third-party action to re-pin.

Verified in Alpine containers with the socket mounted, in all three states this can be in:
nothing installed, the client present and buildx missing — which is exactly what produced
the warning — and buildx unavailable with no package manager to fix it, which warns and
exits 0. The API image builds through BuildKit with no deprecation notice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:19:19 +02:00
jaap-janandClaude Opus 5 5ddbca49d3 Give the image job a docker client to go with the daemon it already had
ci / android head (push) Failing after 5s
ci / build and test (push) Successful in 1m50s
ci / api image (push) Failing after 1m6s
Exit 127, `docker: command not found`, from the build step of the image job. The daemon was
never the problem and never missing: Testcontainers speaks to /var/run/docker.sock from a
.NET library, so every integration suite in the build job had been starting PostgreSQL,
Keycloak and an sshd on this runner while `docker` was not a command on it at all. Having a
socket and having a client are two different things to have, and this runner had one.

It failed late for the same reason it was easy to miss. Node, git, the SDK and the tags all
came up fine, so the job looked healthy right until the line that actually needed the
binary.

The client only. There is a daemon answering on that socket already — installing an engine
would start a second one beside the one in use, which is a worse outcome than the error.

Verified by running this step's own script in an Alpine container with the socket mounted:
it installs docker-cli, the client then reports server 29.6.2 across the socket, and the
API image builds to completion from inside that container with the repository as its
context. Which is as close to the runner as this can be checked without being it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:14:28 +02:00
jaap-janandClaude Opus 5 338c1a8647 Move the plaintext exemption out of the test body it made too long
ci / build and test (push) Successful in 1m47s
ci / android head (push) Failing after 4s
ci / api image (push) Failing after 2s
MA0051: TheWholeSlice reached 68 lines against a limit of 60, because the last commit put a
nine-line paragraph and a five-line call in the middle of it. dotnet format --verify-no-changes
runs the analysers, so the build stopped there and never reached the two fixes that paragraph
was explaining.

The explanation was worth keeping and the place was wrong. It is a fact about one call, not
about the slice, and this file already keeps its steps in named methods under a "Steps"
heading. SignInToTheStackAsync now holds both, which leaves the test body reading as the
sequence it is meant to be — sign in, enroll, unlock, write, read elsewhere — rather than a
sequence with an essay in it.

Nothing about the behaviour changed: same call, same configureOidc, same exemption claimed
by the same single caller that starts the provider it is talking to.

I should also say how this reached CI, since the answer is not that it was hard to catch. I
ran the format gate locally before the last push and read the exit code of a pipeline it was
piped into rather than the tool's own, so a failing command reported as passing. Run again
against the tool's exit status it is 0, and the end-to-end suite still passes in the Alpine
container that reproduces the runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:55:32 +02:00
jaap-janandClaude Opus 5 cf1a321d1e Pin the font the layout suite measures, and let the slice say it means plaintext
ci / build and test (push) Failing after 42s
ci / api image (push) Skipped
ci / android head (push) Failing after 5s
Two failures left on the runner, with nothing in common except that both only appear on a
machine unlike the one anybody develops on. The runner is Alpine, musl, inside a container,
with no fonts installed at all — and that combination is now reproducible locally, which is
how these were fixed rather than guessed at. Both are verified by running the suite in it.

The layout suite had two causes stacked, and the first hid the second completely. Missing
libfontconfig stops libSkiaSharp loading, which the last commit fixed and which then
revealed the real one: Avalonia takes its default font family from the platform, and on an
image with no fonts there is no answer, so FontManager throws "Default font family name
can't be null or empty" inside AppBuilder.SetupUnsafe — before a single test body runs, for
all sixty-eight of them, naming none of their subjects. WithInterFont does not prevent it:
it registers a collection without nominating a default. HeadlessApp's own comment already
claimed it measured "the same Inter font the application registers", which was an intention
the code never carried out.

Both heads now name it, through FontManagerOptions.DefaultFamilyName. That is worth more
than getting CI green: a suite whose entire job is measuring text was taking its metrics
from whatever the machine happened to have — Segoe UI here, DejaVu there — and reporting
the two as one number. It also means the application uses the font it has been shipping and
declining to use since it first referenced the package; almost nothing moves visually,
because App.axaml already sets MonoFont on essentially everything that draws.

The end-to-end slice was the product being right and the test leaning on an accident.
ServerConnection permits an http authority only when it is loopback. Testcontainers reports
the host a container can actually be reached at, so running the suite directly gives
localhost and passes, while running it inside a container gives the bridge gateway
172.17.0.1 and is refused — correctly, since a client that accepted plaintext metadata from
a routable address would be a weakness for everyone who is not a test. Loosening that rule
was the wrong repair. The slice now passes configureOidc and says out loud that it accepts
plaintext from the Keycloak it started itself.

Verified by reproducing the runner rather than approximating it: dotnet/sdk:10.0-alpine,
musl-x64, fc-list returning zero, the docker socket mounted so Testcontainers resolves the
gateway exactly as it does in CI. The whole solution passes there — 19 suites, 0 failures,
4 skipped — and the end-to-end failure was confirmed causal by reverting only that one file
and watching it fail again in the same container. The layout suite also still passes on a
Fedora desktop with 595 fonts, so the two agree now.

Not verified on Windows, and it should be said plainly rather than left to be discovered:
pinning the family changed the measured metrics there too, so a tight layout assertion
could have moved. platform-flags.md records that, and corrects the entry the last commit
added — "libfontconfig, and nothing else" was true of the container it was tested in and
false of the runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:48:30 +02:00
jaap-janandClaude Opus 5 208aca1191 Make a failing test run say what went wrong
ci / build and test (push) Failing after 1m38s
ci / api image (push) Skipped
ci / android head (push) Failing after 5s
Two suites fail on the runner and pass everywhere else, and every attempt to work out why
has been an inference from a filename. The runner prints the path of a log written to a
disk nobody has a shell on, and the log is where the exception type, the message and the
stack all live — so a red build has been a guess, and the last guess was wrong: 69 layout
failures looked like missing fonts and were a missing shared library instead.

This prints the log, and three facts about the machine that no log will ever carry: which
distribution it is and who the job runs as, whether docker answers, and — the one that
matters for the layout suite — ldd against the libSkiaSharp.so the test project carries,
filtered to its unresolved rows. A managed TypeInitializationException on SKImageInfo is a
symptom several missing libraries share; ldd names the library. The fontconfig step ahead
of this exits early when ldconfig already reports one, so if that is present and Skia still
will not load, the answer is a different dependency and this is what says which.

head rather than tail on the log, which is the whole trick. A suite that fails wholesale
writes one stack per test and they are the same stack; the first explains it and the last
two hundred lines are that sentence repeated.

if: failure() and exit 0, so it runs only on a red build and reports without becoming a
second failure on top of the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:32:32 +02:00
jaap-janandClaude Opus 5 43d76d0f2d Let the suite run on Linux, and fix the three things that stopped it
ci / build and test (push) Failing after 1m47s
ci / api image (push) Skipped
ci / android head (push) Failing after 5s
The pipeline finally reached the tests and found four failures. None was the pipeline's,
and only one of the four was a test being fussy about a platform rather than telling the
truth about one.

The local pane's roots bar was the real bug. LocalDirectory.Roots built it from
DriveInfo.GetDrives on every platform, and its own summary — "the drives on Windows, and
the root elsewhere" — had been describing an intention rather than the code for as long as
nobody ran it off Windows. On Unix that call answers with every mount the kernel holds:
/proc, /sys/fs/bpf, one per installed snap, /run/user/1000/doc, some forty on an ordinary
laptop. The transfers screen draws a button per root, so the bar ran to about five thousand
pixels inside an eight-hundred pixel window. Anybody running the Linux build has been
looking at that.

Filtering GetDrives is not the fix and the comment now says why at length, because it is
the obvious thing to try: DriveType answers Fixed for / and /home and equally for every
squashfs snap, for efivarfs and for tracefs, while /boot/efi comes back Removable, and
DriveFormat would need a hand-kept list of every virtual filesystem Linux might grow. So
Unix now names what somebody would want instead of subtracting what they would not — the
root, their home, and whatever is mounted under /run/media/<user>, /media, /mnt or
/Volumes. Anything else is still reachable by navigating from /, which is what the pane is
for. Windows is untouched.

ClientPathsTests looked for "odoSSH" in the profile directory. ClientPaths spells it
DodoSSH on Windows and dodossh on Unix deliberately, one per platform convention, and that
substring was clever enough to survive either spelling of the leading D while still only
ever matching one of them. Now OrdinalIgnoreCase.

WhyTheWindowItselfIsNeverShown asserted a COMException with HResult RPC_E_CHANGED_MODE,
which is WebView2 refusing an MTA thread — a Win32 component raising a COM error. On Linux
the adapter is a different implementation with no apartment to disagree about, so showing
the window works and Should.Throw catches nothing. Skipped there rather than loosened to
accept both outcomes: the assertion is the documentation in that test, and one that passed
everywhere would have stopped recording the constraint it exists to record.

The fourth was CI's alone, and the diagnosis is the useful part. All 69 layout tests failed
on the runner while 6 failed here, which looked like missing fonts and was not: Avalonia's
headless renderer is Skia, libSkiaSharp.so links against libfontconfig, and without it the
suite dies in HeadlessUnitTestSession with a TypeInitializationException on SKImageInfo
naming none of its actual subjects. The job installs the one library now. Verified in a
container where fc-list returns zero and the suite passes regardless, because the
application carries Inter itself — fonts were never the problem, only the thing that would
have looked for them.

The whole solution now passes on Linux: 19 suites, 1295 tests, 0 failures, 4 skipped, the
end-to-end Testcontainers suite included. README and platform-flags.md said testing was
Windows-only, which CI now contradicts on every push, so both say what is true instead and
the two findings are written down where the next person will look for them. macOS is still
untested and now says so on its own rather than hiding inside "not Windows".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:28:06 +02:00
jaap-janandClaude Opus 5 71c0bd8882 Ask global.json for an SDK version that exists
ci / build and test (push) Failing after 2m27s
ci / api image (push) Skipped
ci / android head (push) Failing after 52s
setup-dotnet refused the file outright: "Version '10.0.0' is not valid for the 'sdk.version'
value in global.json. When 'rollForward' is specified, a full SDK version is required."

It is right, and the mistake is a category one rather than a typo. 10.0.0 is a runtime
version; SDK versions carry a feature band, so the first SDK of this major is 10.0.100 and
there has never been a 10.0.0 to roll forward from. The local dotnet accepted it because it
resolves a floor loosely, which is exactly why this survived to CI — nothing on a developer
machine ever disagreed with it.

10.0.100 with the same latestMinor keeps what the file meant: any 10.x SDK, newest wins.
Verified against both SDKs in play, 10.0.109 locally and 10.0.302 in the build container.

Left floating rather than pinned, and worth being honest that this is the shakier half.
IsTrimmable on Contracts and Crypto pulls in Microsoft.NET.ILLink.Tasks, whose version
tracks the SDK's patch and is therefore written into packages.lock.json — so the day a
newer 10.x SDK appears on the runner, --locked-mode fails until the lock files are
regenerated against it. Pinning an exact version with rollForward disabled would end that,
at the cost of everyone installing that SDK exactly; it is a real choice and not one to
make silently inside a fix for something else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:07:30 +02:00
jaap-janandClaude Opus 5 a6af93148b Give the runner a node before asking it to run an action
ci / build and test (push) Failing after 5s
ci / api image (push) Skipped
ci / android head (push) Failing after 3s
Every job died on its first line: "Cannot find: node in PATH", from actions/checkout.
act_runner executes each `uses:` action with node inside the job container, and the image
this runner is configured with has none — so nothing in the pipeline had run yet, including
the tests the image job gates on.

A `run:` step is shell rather than node, so one placed ahead of the first action can fix the
job it is in. It installs via apt-get, apk or dnf, whichever is there, and says plainly what
to do when none of them is. git goes in alongside, named in the step rather than smuggled
into it: checkout shells out to git the moment node has loaded it, so an image thin enough
to lack one usually lacks the other, and finding that out separately costs another round
trip through CI.

The version is warned about, not enforced. Distributions pin nodejs to whatever shipped
with the release — Ubuntu 24.04 still serves 18, past end of life and older than these
actions declare — but act_runner hands an action whichever node is on PATH regardless of
what it asked for, and it generally works. A warning is the right weight for something that
explains a later inexplicable failure without being one.

Repeated verbatim in all three jobs. It cannot be a local composite action, since that
needs the checkout it exists to unblock, and YAML anchors that would deduplicate it are
rejected by GitHub's parser. Byte-identical across the three so a diff shows drift.

This is still a workaround. The fix is one line of the runner's own config.yaml pointing
container.image at an image that ships node, as Gitea's default
catthehacker/ubuntu:act-latest does; the step then costs a version check and nothing else.
Kept regardless, because a pipeline that silently depends on a runner being configured
correctly elsewhere fails confusingly when it is not.

Verified by running the step's own script in ubuntu:24.04 and alpine:3.20, which have
neither, and node:20-bookworm, which has both: installs where needed, no-ops where not, and
warns only on the node 18 that Ubuntu gives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:04:50 +02:00
jaap-janandClaude Opus 5 57d4b30557 Put the app's own mark on the launcher
ci / build and test (push) Failing after 3s
ci / api image (push) Skipped
ci / android head (push) Failing after 2s
The sign-in and locked screens both draw the same thing — a square outline in the accent
with >_ inside it — and the launcher was still showing the stock Android silhouette, so the
icon somebody taps and the icon the app opens onto had nothing to do with each other.

Redrawn as a vector rather than exported from the screen as a bitmap. There is one geometry
here and no set of density buckets to update four of and forget the fifth, and the accent
stays a number that can be diffed against Palette.axaml rather than a colour baked into a
PNG. The hex is written out because an Android resource cannot reference a XAML dictionary
— the same duplication colors.xml already carries for the window background, with the same
obligation attached.

Adaptive only, no raster fallback. Adaptive icons landed in API 26 and this head requires
28, so there is no device it ships to that would need the bitmaps; density buckets exist to
choose between PNGs and there is nothing to choose. The background layer is the same
@color/dodo_window as the window and the status bar, so the mark sits on the app's own
near-black rather than on a second one almost like it.

Two departures from the screen, both because a launcher is looked at much smaller than a
sign-in header. The box is 42 across rather than the 48 that first suggested itself: 72 of
the 108 survives masking, but that is a width, and a square meets a circular mask at its
corners — at 48 they land 33.9 out against a radius of 36 and read as clipped despite
technically clearing it. And the strokes are 2.2 and 2.8 where proportional fidelity to a
1px border on 44px would be 1.0, which a launcher drawing this at 48dp would render as half
a pixel of nothing.

A monochrome layer too, for the Android 13+ themed-icon setting. Without one a launcher
with themed icons on falls back to the full-colour icon, which would leave this the single
green thing on an otherwise recoloured home screen.

Verified in the packaged APK: the icon resolves at all five densities, the three layers
resolve, and the compiled vector carries the geometry above. The launcher rendering itself
was checked against local renders under circular and squircle masks at 144 and 64 px, not
on the device — the phone was locked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:46:40 +02:00
jaap-janandClaude Opus 5 8a568117df Give the API an image, and unbreak the restore that had to run first
registry-docker.dodotech.cloud/dodotech/dodossh-api, built and pushed by a third ci job
that needs the first. Gating on the tests costs a few minutes on every main commit and buys
the only thing worth having here: an image is not an artefact somebody inspects before
using it, so a red commit must not be able to produce one. Pull requests build the image
and stop, which is where a broken Dockerfile should be found.

Tags are :sha-<short> on every build, :main on main, and for a v* tag :1.2.3, :1.2 and
:latest — the last two only when the version has no prerelease suffix, since v1.3.0-rc1
sorts above v1.2.9 and would otherwise walk :latest onto somebody's server. Only sha- is
immutable, and it is the one to pin a deployment to.

No docker/* actions. The build is single-architecture, so it needs the daemon this runner
already has for the Testcontainers suites and nothing else — no buildx, no QEMU, and no
third-party action whose SHA has to be audited and re-pinned. Step outputs and secrets
reach the shell through env rather than ${{ }} interpolation, because a git tag may contain
a semicolon and interpolation is textual substitution performed before the shell parses the
line.

The image is chiseled: no shell, no package manager, uid 1654. Affordable because
Directory.Build.props already sets InvariantGlobalization, so the ICU and tzdata a normal
base carries are exactly what this product decided not to use. The cost is stated in the
Dockerfile rather than hidden — there is no HEALTHCHECK, because there is nothing to run
one with, and /healthz/ready is anonymous precisely so the orchestrator can ask instead.
Nothing migrates the schema from inside the container either; readiness fails while a
migration is pending and names it, which is the design.

And the restore that all of this depends on did not work. 7a3a521 committed lock files
carrying a net10.0/android-arm64 section into fourteen projects — written there by the
Android head's -p:RuntimeIdentifier=android-arm64 packaging build, which restores the
shared projects with a RID and updates their lock files as a side effect. Any restore
without that RID then fails NU1004 in locked mode, which is every other build there is:
`dotnet restore DodoSSH.slnx --locked-mode` has been failing for eleven projects on a clean
checkout of main since that commit. The sections are removed here and nothing else changed
— deletions only, ILLink.Tasks stays at 10.0.10.

Verified: the solution restores in locked mode, the image builds, and it runs. /healthz/live
answers 200 and /healthz/ready answers 503 naming the database it cannot reach, from a
67 MB image as uid 1654, configured entirely through DODOSSH_-prefixed variables.

The Android head's own lock file still carries the RID and is untouched, because that job
restores it separately and is outside DodoSSH.slnx. Whether its packaging step re-dirties
these fourteen on every CI run is worth a look; it is the same mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:40:12 +02:00
jaap-janandClaude Opus 5 215e73b07f Let the phone's theme past the activity it is attached to
The head has now run on a device, and the first thing it did was die on the way up.
DodoTheme parented @android:style/Theme.Material.NoActionBar, but AvaloniaMainActivity
descends from AndroidX's AppCompatActivity, which asserts its own theme attributes while
inflating and throws — "You need to use a Theme.AppCompat theme (or descendant)" — before
a single Avalonia frame exists. The platform's own parents are the ones that look right,
which is why the audit read as correct and the launcher icon still opened onto a splash
screen and then nothing.

Theme.AppCompat.NoActionBar instead, dark rather than .Light because every override below
it repaints the window near-black regardless. The no-action-bar and status-bar decisions
those overrides carry are untouched, so the reason they are there — a header that has to
hold the vault name, and a clock that would otherwise be dark-on-dark — still holds.

Verified on a OnePlus CPH2765: builds, deploys, and reaches the sign-in screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:16:56 +02:00
jaap-jan 4300d917a8 Stop making people wait for a handshake, and give the host list a pointer
ci / build and test (push) Failing after 3s
ci / android head (push) Failing after 2s
Connecting held the vault's busy gate, which meant a window that did nothing visible for
as long as a machine took to answer — and against one that is merely asleep, that is the
whole timeout. The gate is gone from that one command. A tab now appears in the strip in
the same turn as the click, carrying "connecting…" rather than a pane, and the terminal's
rectangle draws a card naming the host and the address being dialled. Every other screen
stays usable, and two connections can be in flight at once.

That splits the vault's one connection event into three, carrying an attempt id, because
"which tab is this about" can no longer be answered by "the most recent one". The id also
buys the two kinds of not-connecting their different endings: a refusal stays in the strip
as a tab holding its reason, since by then the user is quite likely three screens away and
a status line they are not looking at is not where a failure should end; a host key
question takes the tab away and puts the window back on HOSTS, because the prompt is drawn
there and a tab claiming failure would be competing with the thing about to resume it.

ConnectAsync takes no CancellationToken any more, and that is load-bearing rather than
tidying. A [RelayCommand] over a method that takes one generates a command that cancels
the previous execution's token on every invocation — so asking for a second machine
silently abandoned the first, measured as the first tab disappearing with "Cancelled." the
instant the second was asked for. Giving up on a connection is closing its tab, and a
session that lands after that is adopted rather than dropped: a shell running with nothing
naming it cannot be closed at all.

A tab is marked active on IsShowing rather than IsSelected. The selection survives
navigating away — that is what makes the strip a way back to a terminal instead of a way
to lose one — so a tab lit while preferences filled the window was a second "you are here"
mark pointing at something nobody could see. The nav rail's own entries have always made
this distinction.

The host list grows the two gestures it looked like it already had. A right click selects
the row under the pointer before opening a menu of Connect, Edit and Delete — the menu is
on the list rather than in the item template, so its entries are the vault's own commands
and not a row's, and it is cancelled outright over a group heading. Dragging a host onto a
heading files it there, onto a host files it beside that one, and onto UNGROUPED takes it
out of a group; the write is one field of one host through the same repository a save
uses, refused while the editor is open because a drop is a gesture on the list and not on
a half-typed form.

Clicking a result in the palette connects, which is what a list of hosts under a search
box looks like it does. It went through the shell's own command, so the pointer and Enter
take one path.

And the files screen's two pickers followed the vault's lists once, at unlock: a host or a
bucket created afterwards could not be picked until the keychain had been locked and
opened again, with nothing on screen explaining why the machine plainly in the host list
was missing. They follow the collections now, re-finding the selection by id across the
rebuild a sync pass causes every minute.

165 shell tests and 69 layout tests green, including the connecting tab, both failure
endings, two connections at once, a connection in flight across a lock, and the right
click acting on the row under the pointer rather than on the selection. The drag itself is
in docs/manual-checks.md with the rest of phase 7 — headless Avalonia has no platform
drag, and a test that claimed to have dropped something would pass while confirming
nothing.
2026-07-31 22:59:33 +02:00
jaap-jan 7a3a521c59 Give the phone the rest of its screens, and a way in
ci / build and test (push) Failing after 2s
ci / android head (push) Failing after 1s
All seven screens of the design, plus the two it does not draw because it starts at an
enrolled phone: naming a server, and choosing a passphrase.

The five states docs/android-port.md worried about losing at 360dp are all here and none
of them softened. The changed-key refusal is a full-screen panel rather than a bottom
sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way
forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards,
so the sentence about screenshots is true rather than decorative. The delete
confirmations keep their counts and replace the row in place.

Signing in works, and the seam it needed is worth more than the implementation:
IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two
heads differ in where the response arrives and in nothing else. PKCE, the state check,
discovery, the token exchange and the key binding stay one implementation — a second OIDC
client would be a second place for a security bug to live. The phone registers a
private-use scheme with the system rather than binding a loopback port, which on a shared
device any other app can do first.

The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from
the renderer straight down the socket, and there was no way in for the keys a software
keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is
drawn — a modifier that is on and does not look on is how somebody sends ^L to a database
prompt believing they typed an l.

597 client tests green, including two new ones for the input path and one for the
terminal surface command. Nothing has run on a device.
2026-07-31 21:43:11 +02:00
jaap-jan 81e7e6d939 Write down what the phone found, and stop it rotting
docs/android-port.md was an audit of work not started; it now says what is built. Three of
its statements needed correcting rather than extending, and they are marked where they sit:
the Android version question is settled and was never as open as it looked, because
Avalonia.Controls.WebView ships only a net10.0-android36.0 assembly and nothing lower can
resolve it; cleartext to loopback has to be permitted explicitly, which the audit missed
entirely; and the spike produced a structural change it did not anticipate, in
DodoSSH.Client.Shell.

A CI job of its own, because the head is deliberately not in DodoSSH.slnx and a project
outside the solution is a project nobody notices breaking. It packages as well as builds:
a native library with no Android ABI and an assembly that will not dex are both invisible
to a compile, and both are exactly what this head is exposed to.

The README says plainly that signing in is not built, that a fingerprint re-enrolment
destroys the device key, that a notification appears while a shell is open, and that none
of it has run on a device.
2026-07-31 21:09:42 +02:00
jaap-jan 2caedd93ff Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
2026-07-31 21:03:22 +02:00
jaap-jan fe9d7fc289 Give DodoSSH a phone, and a shared shell for both heads to drive
The Android head from docs/android-port.md, taken as far as its step 6.

Step 3, the spike, is answered and its throwaway screen is gone: libsodium.so and
libe_sqlite3.so are both in the arm64 APK, so NSec resolves its native half on Android
despite shipping no Android build, and the local cache opens. Two findings the audit
could not have had: Avalonia.Controls.WebView only ships net10.0-android36.0, which
settles the open "which Android versions" question at targetSdk 36; and Android has
blocked cleartext HTTP since API 28, so the terminal renderer needs a network security
config scoped to 127.0.0.1 or the WebView loads nothing.

DodoSSH.Client.Shell is new and is why the phone can exist: the view models, the terminal
renderer files and the palette moved there so both heads drive one state machine and draw
from one set of tokens. The desktop head is otherwise untouched and its 144 tests still
pass.

The platform pieces behind interfaces that already existed: the profile directory from
filesDir, a device key wrapped by a StrongBox-backed key that a fingerprint releases, and
a foreground service so a shell outliving a vault lock stays true on a platform that
stops backgrounded processes.

Sign-in is deliberately absent rather than approximated. It needs an app link, because
reusing the desktop loopback listener is the attack RFC 8252 section 8.3 names.
2026-07-31 20:58:48 +02:00
jaap-jan 5cbda59a34 Merge branch 'main' into claude/host-management-ui-plan-7f20ab
Seven files needed a hand. Most were two branches adding something in the same
place, but three were one branch changing what the other had moved or renamed,
and those are the ones worth reading.

The shell keeps both new fields and both constructor lines: the connection
recorder this branch built and the teams view model main did. Where main put a
teams load inside OnScreenChanged, it now sits beside the logs refresh rather
than inside RaiseSurfaceState — this branch extracted that notification block
and it is called from two properties, so a screen-specific side effect in there
would fire on every terminal switch as well.

Main gave four row types a vault id and a vault name, and this branch had moved
one of them — KnownHostRowViewModel — into its own file when the pinned keys
became a screen. Git resolved that as "deleted here, modified there" and took
the delete, which compiles as long as nobody looks: the moved copy still had
the two-argument constructor and the call site had grown to four. Carried over
by hand, along with the ordering the pins list now does on them.

The status line's quiet rule was the subtle one. Main extracted it into
IsWorthReporting; this branch had changed the same condition to read item
counts rather than raw ones, because every user action queues a log entry a
moment later and this machine reads its own entries back on the next pull. Take
main's structure and the merge builds, passes, and silently restores a bug this
branch existed partly to fix — every save's message overwritten a second after
it appears. The method now reads PulledItems and PushedItems, with the reason
in its remarks.

Two conflicts were prose that had gone stale rather than code. The keychain
screen's comment said team vaults are refused by the server's access service,
which was true when it was written and is not now; main's replacement stands,
in this branch's vocabulary. The design-gaps row for groups was claimed by both
— real host groups here, per-vault headings there — and they are different
things, so both rows stay and the difference is stated: a group is a shelf the
user chose, a vault is who can read the item.

One defect the tests found and the compiler could not. Generating a key opens
the same editor as pasting one, but not through NewKey — so it never set the
target vault main added, and a generated key was filed into whatever vault was
edited last, or none. Both key-generation tests failed on it. Fixed where the
editor opens, with the reason recorded there.

One gap is left deliberately and is written down rather than half-built. Hosts,
keys, credentials and pins are read across every vault this session holds a key
for; groups are read from the active vault alone, so a host a teammate filed
shows under UNGROUPED. Nothing is lost or misfiled — it is what the sidebar
already shows for a group that has been deleted — but closing it needs a vault
id on every group row for rename and delete, and a way to tell two vaults'
identically-named groups apart under a layout with one heading per group. Both
are worth doing and neither is a merge's business. It is in the remarks on
ReloadGroupsAsync and in docs/design-import-gaps.md.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1282 tests, including the end-to-end suite against real containers.
2026-07-31 20:44:39 +02:00
jaap-jan d07b336868 Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain
hid every open terminal and the strip that named them. A connection you had
opened was invisible from four of the five screens. The window now has two
surfaces rather than one: a nav rail that says which page you are on, and a
terminal strip that is always there and switches the whole content area to a
shell. Screen keeps meaning "which page" and never becomes a sixth kind of
page, which is why this is two properties instead of one enum with a terminal
member in it.

Every screen lives inside one wrapper panel that collapses when a terminal is
showing. That is not tidiness — the WebView hosts a Win32 child window that
composites above everything Avalonia draws, so a screen left visible over its
rectangle is a screen sliced in half, and this window has shipped that defect
once already. One decision point, IsTerminalShowing, and a nested panel rather
than five compound bindings nobody would remember to extend.

The focus choreography is the part no test in this repo can see. Every reveal
path now focuses in the same turn the WebView appeared, so all three of them
post at DispatcherPriority.Loaded and let the native control re-push its bounds
first. Going the other way had a real bug: the screen-changed branch called a
bare Focus() where it had to release the keyboard from the native child, so
switching from a terminal to Files silently ate the first keystrokes. Rare
before this commit and the primary gesture after it.

The tab strip grew a cross inside each tab, a plus that opens the quick-connect
palette, and middle-click close. Nested buttons are correct here: Avalonia
handles a left press on the cross and deliberately does not handle other
buttons, which is exactly what lets middle-click bubble up from the cross as
well as the tab. The test is PointerUpdateKind rather than
IsMiddleButtonPressed, because the latter reports button state and is also true
for a left press made while the middle button happens to be held. The handler
is on the tab and not the strip, so the background closes nothing by
construction. Plus opens the palette rather than a flyout, since a menu
dropping into the WebView's rectangle may or may not composite above a child
HWND and this repo does not make rendering claims it has not photographed.

Everything a user reads now says keychain. The wire, the database and the
cryptographic spec still say vault, deliberately: renaming those is a migration
and a protocol change for a word. That split is written down rather than left
to be rediscovered as an inconsistency.

Four things that were squeezed into the keychain's category rail, or into
nothing at all, now have screens. Pinned host keys get one, with fingerprints
never truncated and a filter that matches them, because comparing what you have
against what the operator published is the whole workflow; the approved date is
read out of the item's UUIDv7 rather than added as a column, and says so, since
it means first approval and not last use. Keys can be generated in the client,
which needed the openssh-key-v1 container written by hand — there is no BCL or
NSec helper, and the PKCS#8 route is unverified in the SSH library this uses.
The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is
Blowfish with a swizzle, in a project whose crypto is otherwise entirely
libsodium, for a protection the key's own remarks argue is redundant inside a
vault. Generation fills the existing editor and stops, so SAVE stays the one
thing that writes. ~/.ssh/config can be imported behind a preview that is
ticked per row and writes nothing until the button; IdentityFile records the
path and imports the key material only on an explicit opt-in, because reading
somebody's private key into a vault is precisely the act this product exists to
make deliberate. Match blocks and ProxyJump are reported rather than obeyed —
one cannot be evaluated statically and the other has nothing behind it to route
with, and a preview that implied otherwise would be worse than one that admits
it.

Files can be dragged in all four directions that are honestly available. Remote
to Explorer does not ship and is not pretended to: the shell wants the bytes
during the drop, which needs a virtual file and a native COM data object,
outside what Avalonia offers. Note for the next person that Avalonia 12
replaced the drag model outright — DataObject and DataFormats are no-op stubs
and IDataObject is not in the reference assembly, so every tutorial written for
11 does not compile here.

Hosts can be grouped, flat and never nested. A parent id merged as a scalar
lets two offline clients each re-parent A under B and B under A, producing a
cycle inside an encrypted payload that no server can police and every reader
would have to detect for ever. Membership lives in that payload rather than in
the one plaintext concession ADR 0001 allows, whose test is that the relay
cannot function without it — nothing on the server reads a group, so what
plaintext would hand over is a clustering of the estate for nothing. The
plaintext column reserved for it is dropped, provably always null, and the
server now refuses a client that sends one; it was never populated, was copied
on apply, and was not cleared on delete, so a group id would have outlived the
host it described.

Snippets insert through xterm rather than through the pump, because xterm is
the only thing that knows whether the remote has bracketed paste on, and that
is what makes a shell treat embedded newlines as text instead of as execute.
The host process moves opaque bytes and never parses output, so it would have
to guess, and guessing wrong runs every line. Running is off by default and the
copy says the text goes into whatever is there — the terminal has no notion of
being at a prompt, and may be in vi or at a password prompt with echo off, so
the Enter the user presses themselves is the entire safety property.

Connections and keychain changes are recorded as synced encrypted items, which
is what makes them auditable by a team later and costs the server knowledge of
connection rate and timing from row counts alone. ADR 0001 already concedes it
cannot hide that class of metadata; the trade is now written into it rather
than left implicit. A connection entry is written once, at close, which is what
makes a synced log tractable: nothing to merge, one outbox row, no chance of
colliding with itself. Live sessions come from memory, not from the log. The
write is void by contract and posts to a bounded channel, because putting an
encrypt-and-write on the teardown path of every session is how closing the
application comes to take four seconds. A ticket opened before a lock still
closes afterwards, since a shell outlives the vault. The activity log hooks the
one generic repository every kind writes through, so it cannot miss a caller —
which is also why the log kinds themselves declare they are not audited, or the
first entry would write an entry about writing an entry. It records the names
of the fields that changed and never their values; a log with an old password
in it would be a plaintext credential store with no vault around it. Retention
is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop
rather than on a second timer.

That log traffic then broke the status line, which is worth recording because
the fix is a shape and not a patch: background sync counted its own log rows as
pushed items, so the quiet rule stopped being quiet and every action's message
was overwritten a second later by a sync report. The report now separates log
rows from user items and the rule reads the latter.

S3 buckets appear as a remote in the file browser, behind the same interface an
SFTP session implements, so the queue and both panes did not have to learn what
they are talking to. Uploads go through a pipe, because the queue wants to
write and the SDK wants to read; memory is then bounded by the part size
instead of buffering a file to disk twice.

Finally, the Windows device key store moved out of the session project, which
was the one thing keeping it from being portable — everything else in it is
platform-neutral, and a Windows CNG dependency in the middle of the vault code
meant a second head could not reference it without dragging Windows along. The
seam that made the move free was already there. docs/android-port.md is the
audit behind that: what ports, what does not, in order of cost, the four
decisions taken, and an inventory of every screen and state the interface has
to carry, written so a design can be made from it directly.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1240 tests at zero warnings, including the end-to-end suite against real
containers. The manual checks that headless Avalonia cannot make — the drag
from Explorer, a generated key against a real host, twelve tabs at the minimum
window width — are listed in docs/manual-checks.md and are still outstanding.
2026-07-31 20:30:05 +02:00
jaap-jan 23eca3a21b Merge branch 'main' into claude/m3-implementation-57f9d7
ci / build and test (push) Failing after 2s
Three files conflicted, and two of the resolutions are more than a choice of
side.

QuickConnectTests had both branches fixing the same build break — main's M2
merge left the shell's constructor with an ISftpSessionFactory nobody passed.
Main's version wins because it carries a comment saying why the palette never
needs a session.

VaultSession's conflict is adjacent edits: main added the remembered sign-in
members and this branch changed SyncAsync's summary from "the active vault" to
"one vault". Both kept.

VaultViewModel is the one that matters. Main taught the background pass to
report a sync that had to start over, on the grounds that a machine which
silently re-read a whole vault has had something happen to it; this branch
turned a pass into one report per readable vault. Taking either side alone
would have lost the other, so ResyncedFromStart is now one of the conditions
IsWorthReporting checks, per vault.

Merging also broke something neither branch could have caught alone, and the
build would not have said a word. SyncOnceAsync cleared LastSyncFailed
unconditionally, which was right while a pass was one vault and a failure was
an exception that never reached that line. A failure is now a report — one
unreachable team vault must not stop the others syncing — so the flag was being
cleared over a vault that had just failed, lighting the titlebar SYNCED. It is
computed from the report instead, in the one place both callers go through, so
the manual command gets it as well as the loop. The background pass still
swallows the message and keeps the fact, which is what
AnAutomaticPassThatFails_LeavesTheStatusAlone is there to hold it to.

Two comments the auto-merge left describing a world with one vault in it: the
SCOPES rail's, which said team vaults are refused by the access service, and
the host sidebar's "One heading, for one vault".
2026-07-31 12:26:59 +02:00
jaap-jan 95816de0c5 Share a vault with a team, without the server holding a key
M3's teams, sharing and ACLs. Teams with roles, a public-key directory, the
append-only key log served for clients to check it against, team-owned vaults,
and vault key grants wrapped by a client and stored opaquely by the server.
VaultAccessService resolves team membership to PermissionFlags, so a viewer may
pull and may not push; the desktop client reads and syncs every vault it holds
a key for, and a real TEAMS screen replaces the one that said it did not exist.
No migration: team, team_membership, vault.team_id and vault_key_grant have all
been there since the first one, which is what carrying two unused tables bought.

Membership is authorisation. A grant is access. The obvious model is one
concept — "access", with a role attached, handed out by the server — and this
architecture cannot implement it: a vault key is sealed to each member's X25519
key, and only a client holding the plaintext can seal it for somebody else. So
"give Bob access" decomposes into a database write and a wrap, which happen on
different machines. Adding a member makes the server serve them the vault; it
cannot make it readable. VaultSummary.WrappedVaultKey is null in the meantime
and the vault appears in their list saying it is waiting for a key, because
hiding it until a grant existed would have been tidier and would have implied
the server was the thing granting access. The screen says the same thing after
every add, in the status line. ADR 0009 records the whole decision.

Sharing verifies or refuses. A directory lookup is a claim by the server about
a third party's public key, and wrapping to an unverified claim hands the vault
to whoever made it — no amount of transport security helps, because the server
is inside the threat model. KeyLogAudit reads the whole log, recomputes every
entry's hash from its own contents, checks the chain from genesis, and refuses
unless the offered key appears in it unchanged. There is no override flag: one
that exists gets used on the day the log is briefly unreachable, and the
resulting grant is indistinguishable from a correct one afterwards. What it
still cannot promise is that the key is the right person's, so the fingerprint
comes back for an out-of-band comparison and the success message says so every
time. A test corrupts the fake server's log by one byte and watches the client
refuse rather than warn.

The roles are only the ones that are enforceable. There is no ConnectOnly,
despite the design asking for one and TeamRole having room: SSH terminates on
the client, so a session needs the credential's plaintext on that machine, and
"may connect but may not read the key" cannot be enforced here. Shipping it as
an option in a dropdown would have been a lie. Connect rides along with Read
and is documented as an interface hint. Removal is named for what it does — it
revokes grants and flags the vault for rekey, and claims nothing about what is
already on somebody's laptop.

Three things are deliberately absent, and each is a refusal rather than an
omission. The rekey itself, because re-wrapping every item's data key under a
new vault key needs a client holding the current one; the server records that a
rotation is owed and the interface reports it, which is more honest than a
button that only appears to do it. Ownership transfer, because allowing an
owner to be removed without one leaves a team nobody can administer. And
cross-vault host key trust: a pin in a team vault is listed but not consulted
at connect time, because any member with Write could otherwise pre-approve a
fingerprint another member's client then trusts silently for a host in their
own vault. Scoping trust properly needs a scope on the SSH connect path, which
IKnownHostStore has not got; until then the narrow direction is the safe one
and the cost is in the README rather than hidden.

Reading now spans vaults and writing still does not. Every list on the vault
and hosts screens covers each vault the keyring opened, rows carry the vault
they came from, and an edit goes back to that vault rather than to the active
one — writing it to the active vault would fork the item and only show up when
a colleague wondered why their change never arrived. A new item goes wherever a
picker says, defaulting to the personal vault and never moving on its own,
because an item filed into a team's vault is visible to that team and moving it
back means deleting and retyping. The sidebar heading stops naming one vault
once there are two, and each row names its own.

The server checks what it can and nothing it cannot. It will not record a grant
for a key its recipient no longer holds, for a superseded generation, or for
somebody who is not in the team — each of those would otherwise surface days
later at the far end as a tag failure indistinguishable from corruption. It
does not verify the wrap or the signature, and the grant service says so: that
would be a convenience and never the boundary, and would put an asymmetric
implementation on a machine that is supposed to hold no keys.

Two bugs the tests found. TeamsViewModel's busy gate blocked its own reload, so
a team created a moment earlier was missing from the list it had just been
added to. And syncing every vault turned a failure from an exception into a
report, which made a background pass announce an unreachable vault once a
minute — the exact behaviour AnAutomaticPassThatFails_LeavesTheStatusAlone
exists to prevent. The fact is recorded and the message swallowed, as it was
before; pressing Sync still names the vault and the reason.

Also fixes a build break this branch started with: QuickConnectTests was never
updated when M2 added ISftpSessionFactory to the shell's constructor, so
nothing built at all.
2026-07-31 12:18:28 +02:00
jaap-jan 03e902a2d2 Colour the host's file rows by what their mode says
ci / build and test (push) Failing after 2s
The remote pane's NAME column was blue for a directory and plain for everything
else, and the PERMS column was faint whatever it said. Two colours now come off
the mode, split across those two columns on purpose: NAME says what a row is,
so a file with an execute bit is green there, and PERMS says what is notable
about how it is set, so a file anyone may write to is amber over the characters
that actually say so. Because the two never compete for one TextBlock, a
world-writable executable shows both facts instead of one winning an argument.
No new blue is spent, which is what App.axaml asks for: it reserves blue for a
directory, a distinct scope, and calls it deliberately rare.

Both are files only, and each exclusion is a wrong answer avoided rather than a
case not got to. Every symbolic link is lrwxrwxrwx by convention and its mode
governs nothing — what may be written is the target, whose mode an lstat
listing never fetched — so amber there would fire on every link on the host. A
world-writable directory is /tmp, made safe by a sticky bit PosixMode does not
render, and warning about it would be warning about the half of the mode that
is on screen while the half that answers the warning is not. And the execute
bit on a directory means "may be searched", which is true of very nearly every
directory a host has, so green there would paint the whole pane and mark
nothing.

The two questions read back the string PosixMode wrote rather than carrying its
nine booleans through SftpEntry as well. That is the point rather than a
shortcut: two representations of one fact is how a row ends up coloured for a
bit the column beside it does not show. A mode of the wrong length answers
false rather than throwing, since these decide a colour and a listing is not
worth failing over one.

The amber is Warn rather than WarnText, which is the muted amber a warning card
writes its sentences in. At 9.5px against TextFaint that one is a shade rather
than a signal, and a marker nobody notices is the same as no marker.

The local pane is untouched, on the grounds it already gives for having no
PERMS column at all: a POSIX mode is not a fact about a file on Windows, and
colouring one there would invent exactly what the column declines to print.

Twenty cases in RemotePathTests, which needs no container — the execute bit in
any of the three triples rather than only the owner's, the others-write bit
alone, a mode of the wrong length, and the file-only rule for both questions
from all three kinds. dotnet format is clean and the app and layout suites pass
at 109 and 35.
2026-07-31 12:17:56 +02:00
jaap-jan 1292084af9 Merge branch 'claude/delete-confirmations-becf4a'
ci / build and test (push) Failing after 2s
2026-07-31 11:52:27 +02:00
jaap-jan 91438fb382 Ask before deleting, and connect a host by double-clicking it
DELETE on a host, an SSH key, a stored password or a file on the host now puts
a question where the button was, and only answering it deletes anything. It is
a state rather than a dialog, which is the arrangement signing out already had
and for the same reason: this is the moment that has to be able to say what is
about to go before it goes.

What the question says is counted rather than generic, because a confirmation
that only asks whether you are sure is a click to train people out of. A key
names the hosts that authenticate with it and says they will refuse to connect
afterwards rather than falling back to a typed password, which is what the
connect path actually does. A host discloses a terminal open on it, because
deleting the host does not close the session. Every vault deletion says how far
it travels and whether this machine can push the tombstone yet or is queuing
it. Deleting on the host carries the strongest warning of the four on purpose:
everything else here is a tombstone against a copy the server still holds, and
a file on somebody's machine is bytes with nothing behind them — so that one
names the full path, since a bare name identifies nothing.

The armed request carries the item's entity id, so nothing that moves the
selection between the question and the answer can redirect it, and answering
about something that has since gone says so instead of doing nothing quietly.
Disarming compares ids rather than rows, which is the subtle half: a reload
replaces every row object, so the naive rule would have let the pass that runs
every minute take the card away from somebody halfway through reading it.

Forgetting a pinned host key is deliberately still unguarded. It costs one
fingerprint check on the next connection and it is the safe direction to be
wrong in — the dangerous button there is the one that adds trust, and that one
is already a prompt at connect time. Discarding a stopped transfer is likewise
unguarded: it removes a resumable part file and leaves the source alone.

Double-clicking a host in the sidebar connects to it, wired as a gesture in the
control exactly as the transfers screen opens a directory. CONNECT stays, since
it is the button with the password box beside it.

Ten existing delete call sites now go through arm-and-confirm helpers, and
eight new flow tests cover asking first, cancelling, the counted warning,
disarming on a selection change and on an editor opening, surviving a sync, and
the stale-item guard. Three layout tests measure the new shapes — the sidebar
card is the one card in the application a user cannot scroll — and one of them
also asserts the card renders its text, because a card whose compiled bindings
did not resolve would lay out perfectly as empty rows. The double-click test
performs the real gesture and proves it reached the connect command through a
refusal that never touches a network.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
853 tests, including the end-to-end suite against real containers.
2026-07-31 11:52:13 +02:00
jaap-jan 9608d73747 Come back from a sync position the server will not accept
ci / build and test (push) Failing after 2s
"The server returned 400: The sync cursor is not valid for this vault. Resync
from the beginning." told the user exactly what to do and gave them no way to
do it. The cursor is the only thing a pull sends, so the refusal was permanent:
the next pass read the same stored cursor and was told the same thing, once a
minute, for ever. And because the pull runs first, the exception ended the pass
before it reached the outbox — so the vault stopped receiving other machines'
changes and stopped sending its own. A machine that met this went quietly
read-only until somebody deleted its cache.

The engine now does what the message asks. A pull refused with the
invalid-cursor problem code — the code, never the prose, which is free to
change — drops this vault's position, writes that down, and reads the log again
from the beginning. The restarted request carries no cursor, which is the one
position a server cannot reject, so the retry cannot loop; a refusal of that is
rethrown rather than retried, and a restart is allowed once per pull. The
position is saved before the replay starts, so a process that dies halfway
through begins the next one from the beginning too rather than meeting the same
refusal again.

The mirror is deliberately kept. Replaying rewrites every row the server still
has and applying a change is a blind overwrite, so the re-pull repairs the
mirror on its way past; clearing it first would claim more than the evidence
supports — the position was refused, not the contents — and would leave a
machine that lost its connection mid-replay with less than it started with.
That leaves one gap, named in the remarks rather than left to be discovered:
once tombstone collection exists, a replay stops carrying deletions older than
the retention window.

None of the causes are the user's doing — a rotated cursor signing key, a vault
served from a restored database, a cache copied between machines — so nothing
asks them to decide anything. The report carries ResyncedFromStart and the
status line says the position was not recognised and the vault was read again.
It is kept out of NeedsAttention, because nothing is outstanding, but the
background pass breaks its usual silence for it: a sync that pulled the whole
vault on a day nobody changed anything otherwise reads as a fault.

The fake server grew a switch that refuses cursors the way a rotated signing
key does, including ones it minted itself. Three cases: the vault is re-read
and the change on the far side of the refused position arrives; the edits
waiting in the outbox are still pushed in that same pass, which is the half
that made this worth recovering from rather than merely reporting; and a server
that refuses the beginning itself is surfaced instead of replayed against.

dotnet build is clean at zero warnings, dotnet format is clean, and the sync
and app suites pass — 109 and 101.
2026-07-31 11:32:14 +02:00
jaap-jan 240aadb746 Merge branch 'main' into claude/vault-unlock-logout-autosync-a84c35
ci / build and test (push) Failing after 3s
Four files needed a hand, and all four were two branches adding something in
the same place rather than either changing what the other did.

The shell's constructor now takes both new parameters: main's SFTP session
factory, which it must have because it builds the transfers view model, and
this branch's optional resume handler, which stays last so every existing test
that constructs a shell without one still gets a shell that can only be online
because somebody signed in during this run. App.axaml.cs, ShellFlowTests and
QuickConnectTests pass the pair; the layout suite keeps both of its new fields.

Signing out now detaches the transfers screen exactly as locking does, and the
confirmation says that an open transfer session survives it. That is the same
policy both sides already argue for their own case: signing out destroys this
machine's copy of the vault, not work that authenticated before it.

QuickConnectTests did not compile on main — the SFTP commit added a constructor
parameter and the quick-connect suite, merged from a parallel branch just
before it, was still calling the old one. Fixed here rather than worked around,
since the merged tree has to build.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
980 tests, including the end-to-end suite against real containers.
2026-07-31 11:16:49 +02:00
jaap-jan d1700f5a34 Merge branch 'claude/m2-file-transfer-1b9951'
ci / build and test (push) Failing after 3s
2026-07-31 11:08:05 +02:00
jaap-jan 0b261c4d39 Stay signed in, come back online by itself, and let a machine be given up
Three things a machine that has been set up could not do. Unlock now takes
Enter, which is the gesture everybody makes after typing a password and which
did nothing until they found the button.

Signing in survives a relaunch. The refresh token is kept in the local cache,
sealed under the vault's own cache key, so a later launch resumes the session
through the refresh grant with no browser and nobody present — and because it
is sealed under that key, only an unlocked vault can resume it. A locked
client therefore cannot reach the server at all, which is a consequence worth
stating rather than working around; docs/crypto.md §3.2 records it. Every sync
pass asks the shell for a connection rather than reading one captured at
unlock, so a laptop that unlocked on a train is online within a minute of
finding a network, with nothing pressed. Unlocking itself still never waits on
a socket.

Signing out empties this machine: the profile, the cached items, the outbox
and this machine's device key, with the account's row withdrawn when the
server can be reached. It asks first and says what it costs — the outbox count
when the vault is open, an admission that it cannot be counted when it is not,
and the shells that keep running either way. The vault is on the server and is
untouched, which is what makes the same button the only honest answer to a
forgotten passphrase, so it is on the unlock screen as well as in preferences.
It cannot end the session at the identity provider, and says so.

Two defects surfaced on the way. The synchronisation pass that runs when the
vault opens never ran at all: the loop is started from inside the unlock
command, so the busy flag it yields to was raised by that command — the first
sync was a minute late on every launch. And signing in from preferences while
unlocked threw an unlock screen over an open vault whose keys were still in
memory.

The unlock card and the new confirmation live in their own controls because
MainWindow cannot be laid out headless, so markup left inside it is markup no
test can measure; both are now measured at the window's minimum size in the
shapes that grow. What is still unverified is the composed window itself.
2026-07-31 11:07:36 +02:00
jaap-jan 04faef6597 Move files to and from a host over SFTP
M2's file transfer, built bottom-up: an SFTP session on the SSH layer, a
transfer queue in a project of its own, and the two-pane browser the design
asked for replacing the screen that said it did not exist. Remote listings
carry names, sizes, modification times and a real drwxr-xr-x — nothing in this
repository could render a POSIX mode before — and the queue moves one file at a
time with progress, throughput and resume.

The design import assumed this would be an SFTP subsystem channel on
ISshConnection, beside the shell on a transport that is already up. SSH.NET
does not offer that: SftpClient derives from BaseClient and owns its own
transport, and there is no supported way to hand it an SshClient's session. So
file transfer opens a second authenticated connection, and it is named for
that rather than dressed up as a channel — OpenSftpAsync is on
ISftpSessionFactory, not on a connection. The difference is visible to a user:
the host records a second login, and a host whose password is typed each time
asks for it again on this screen. It goes through the same host key gate, the
same pin and the same two refusals a shell does, so a fingerprint approved for
a terminal is approved here and one approved here reaches the other machines
with the next sync. docs/design-import-gaps.md is corrected, and marked as the
one row where what shipped differs from what it predicted.

Nothing is written at its final name until it is complete. Every transfer goes
to a .dodossh-part file beside its destination and is renamed into place at the
end, so an interrupted transfer can never be mistaken for a finished one —
which matters most for what this screen is actually for, which is copying a
build artefact onto a server and then running it. A destination that already
exists is refused outright rather than overwritten: the queue has no way to
ask, and silently replacing a file somebody's process is serving is the worse
of the two failures. The remote pane has DELETE and MKDIR so that refusal is
not a dead end. A test against the container pins the assumption underneath all
of this — that SFTP's rename does not clobber.

Resume works within a run of the application and not across a restart, and the
limit is deliberate rather than unfinished. Nothing records which source wrote
a part file, and resuming one on the strength of its name matching is how a
corrupt artefact gets delivered with nothing reporting a failure; a part file
found at startup is started over. Making it survive a restart needs the
preferences store this client still has not got. The offset a resume starts at
is the part file's own length rather than the transfer's recorded progress: a
cancellation can land between a write completing and the counter moving, and
only one of those two is a fact about the bytes that are there.

The queue and its connection outlive a lock, as shells do. LockAsync already
argues that locking must not destroy work in flight — it is what somebody does
when they walk away from the machine, which is exactly when a long transfer is
most likely to be running — so TransfersViewModel is created once and the vault
is attached on unlock and detached on lock. What locking takes is the host
list, and it has to: those rows carry decrypted secrets.

DodoSSH.Client.Transfer is a new project rather than more of Client.Ssh. The
two answer different questions — one is about reaching a host, the other about
moving bytes and what to do when moving them stops halfway — and this is the
only client project that deliberately touches the local filesystem.

Three defects the tests found, none of which review would have. SftpPath.Name
answered an empty string for the root. NavigateRemoteAsync wrapped itself in
the busy guard, so navigating from inside another command did nothing at all
and the remote pane simply stayed empty after connecting, with no failure
anywhere to explain it. And opening an SFTP session per test made two
handshakes per test — this client learns a host key by being refused — which
pushed the SSH assembly past sshd's MaxStartups and failed a different few
unrelated tests each run; the session is shared through the fixture now, with
the reason written where the next person will hit it.

1004 tests green across 18 projects, 24 of them new: the SFTP subsystem against
the OpenSSH container, the queue against a real temporary directory and a fake
host, and three more layout measurements because a screen this window has never
laid out is a screen never checked.

Not verified: the screen has not been looked at running. The layout harness
measures it at the window's minimum in three shapes, which is the class of
defect that has shipped here before, but reaching it in the application needs
the compose stack, the migrations, the API and a browser sign-in. What is still
absent — the status bar's transfer count, dragging between the panes,
transferring a directory, and sftp over a bastion — is in
docs/design-import-gaps.md.
2026-07-31 11:07:29 +02:00
jaap-jan f7c5096bc6 Keep the stub servers on loopback
Running the tests raised a Windows Firewall prompt, and raised it again from
every worktree. WireMockServer.Start() with no settings listens on 0.0.0.0
and [::], and the prompt is keyed to the binary that opened the socket — so
each test executable asks once per bin path, which a new worktree or a switch
between Debug and Release makes new again. The three suites that hold a
firewall rule on this machine are exactly the three that use WireMock; every
other listener in the repository already binds 127.0.0.1.

The stubs now say so explicitly. Port 0 is still WireMock's own free-port
search and still comes back on server.Url, which is what each stub builds its
base URL from, so the authority the API validates against and the issuer its
tokens claim follow the binding rather than being pinned to a host name.

Sampling the listening sockets of a full DodoSSH.Api.Tests run afterwards
finds one, 127.0.0.1, where there were previously three.
2026-07-31 11:06:46 +02:00
jaap-jan 4eaa8eae6d Merge branch 'claude/search-modal-closing-cd05c4'
ci / build and test (push) Failing after 2s
2026-07-31 10:45:46 +02:00
jaap-jan 66271faaae Update .github/workflows/ci.yml
ci / build and test (push) Failing after 16s
2026-07-31 08:44:19 +00:00
jaap-jan 9c3edb078e Update .github/workflows/ci.yml
ci / build and test (push) Canceled after 0s
2026-07-31 08:43:39 +00:00
jaap-jan 312d766c30 Let the quick-connect palette answer for itself
Clicking outside the palette did nothing, because nothing was listening: the
wash took no pointer input at all, so the only ways out were a key and the
button that opened it. It now closes on a press whose source is the wash
itself, which is what separates outside from inside — a press on the card
bubbles through the same handler on its way to the window, and closing on
those would make the palette impossible to click into.

The caret never reached the query box either. The window focused it from the
view model's PropertyChanged, and that handler runs before the binding which
reveals the control — measured, with the same wiring, in a replica window. So
it focused a control that was still collapsed, which Avalonia treats as a
no-op and does not replay when the control is revealed, and the keyboard
stayed wherever the click that opened the palette had left it. Becoming
visible is now what triggers it, posted rather than called: a control that has
never been laid out has no visual children, and at the instant IsVisible turns
true the box still reports IsAttachedToVisualTree() == false.

Escape, Enter and the arrows move to the palette as a tunnelled handler.
Answering them only on the window was fragile in the way that matters here:
anything on the route that took a key first would silence them, and with the
focus never landing in the palette the key was being pressed at whatever the
opening click had focused — a focused Button eats Enter. The window keeps
Ctrl+K, which has to work when the palette is not showing, and forwards the
rest as the net for a press that arrives from outside the palette.

Which is also why this moved out of MainWindow rather than being fixed there.
Showing MainWindow initialises WebView2 on a thread it refuses, so nothing on
that window can be tested — the palette shipped with no test of any kind. As a
UserControl it hosts in a bare window and takes real key and pointer input,
and there are now six: press on the wash closes, press on the card does not,
Escape closes, the arrows move the selection without taking the caret out of
the box, Enter takes the highlighted host, and the palette takes the keyboard
when it appears.
2026-07-31 10:43:30 +02:00
jaap-jan f0002b683c Update .github/workflows/ci.yml
ci / build and test (push) Canceled after 0s
2026-07-31 08:39:25 +00:00
jaap-jan 94e11f5e38 update packages
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
2026-07-31 10:12:05 +02:00
jaap-jan 0b49cfb3c6 Merge branch 'claude/api-fastendpoints-migration-020431'
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
2026-07-31 10:10:22 +02:00
jaap-jan 19dcd4c8e3 Merge pull request 'Give hosts and terminals their own screen, and the rest of the vault another' (#1) from claude/dodo-ssh-design-02b8b9 into main
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
Reviewed-on: DodoTech/DodoSSH#1
2026-07-31 07:26:41 +00:00
jaap-jan 9a76eced14 Give hosts and terminals their own screen, and the rest of the vault another
ci / build and test (ubuntu) (pull_request) Canceled after 0s
ci / build (windows) (pull_request) Canceled after 0s
Rebuilds the client's shell from an imported design: a titlebar and nav rail
it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host
search, and a vault screen that merges keys, passwords and pinned host keys
into one table. Hosts left the vault column for their own screen beside the
terminal, which is what the design asks for and turned out to be the better
split anyway.

Two screens the design shows have nothing behind them yet — file transfer
and teams — and say so plainly rather than rendering invented data; every
other gap between the design and this build is recorded in
docs/design-import-gaps.md.
2026-07-31 08:39:37 +02:00
jaap-jan 9bc28f1c0f Move the API onto FastEndpoints, without moving the wire
Eight endpoints today, around sixty planned. The minimal-API shape — a static
class per area holding static local functions, route and policy and name
asserted in one fluent chain with the handler somewhere below it — has not hurt
yet, and would. A handler's dependencies are parameters rather than injected, a
group's RequireAuthorization sits far from the handler it governs, and there is
no type to hang an endpoint's own documentation on. FastEndpoints is one class
per endpoint, its route and authorization in Configure(), its handler a method
on the same type.

Nothing about the wire moves, and the evidence is that the 94 existing HTTP
tests pass with zero edits to any of them. Same routes, verbs, route
constraints, status codes, operation ids, and the same RFC 9457 bodies with the
same code values. Every place the idiomatic FastEndpoints answer would have
changed one of those, it was refused:

Endpoints are registered from an explicit List<Type>, not found by scanning.
ADR 0002 rejected reflection discovery by name, and the reason it gave is
sharper here than in general — under WebApplicationFactory the scan reaches the
test assembly, so an endpoint written in a test would be registered into the
host under test. The cost is a line per endpoint that can be forgotten, which is
what the endpoint-inventory test is for. That test is the one ADR 0002 promised
and never got.

Handlers still return Results<Ok<T>, NotFound, ProblemHttpResult> from
ExecuteAsync. The union executes as an ordinary IResult, which is what keeps
problem bodies going through the host's serialiser and IProblemDetailsService,
and what keeps the compile-time record of which statuses an endpoint can
produce. No Send.* call appears anywhere; the moment one does, a response has
left the host's serialiser.

Validation stays in the feature services. A Validator<T> short-circuits before
the handler and answers with FastEndpoints' own envelope, which carries no code
— and the code is the only part of an error the client branches on. Twenty-odd
tests assert a specific code on a 400. It is banned in BannedSymbols.txt rather
than merely avoided, because the framework's documentation leads straight to it
and it looks like an improvement.

Three defects arrived with the framework and were caught in review. All three
were green at the time, which is the part worth remembering. FastEndpoints maps
GET /_test_url_cache_ unconditionally, in every environment, with no policy and
no way to opt out; it answers with the whole endpoint-name-to-route table. It is
short-circuited to 404 — by asking routing which endpoint it selected, after the
first attempt compared the request path with Ordinal and was therefore bypassable
at /_TEST_URL_CACHE_, certified by a test that only ever tried one spelling. The
default request binder writes query-string values over the deserialised body,
which would have let ?identityProviderToken=... put an ID token in a URL and from
there into every proxy log on the path; every endpoint now binds from the body
alone. And a route value read with Route<T>() is invisible to ApiExplorer, so the
generated document named {vaultId} in a path template with nothing declaring it —
invalid OpenAPI, and unusable by the client generators the document exists for.

Two changes to the surface, both deliberate. A body that cannot be deserialised
now answers with a problem document carrying malformed-request, rather than an
empty 400: FastEndpoints' default announces application/problem+json while
sending something else, and names the failing .NET type on the wire, in a
codebase that sets IncludeErrorDetails = false to prevent exactly that. And the
route table above returns 404 where it would otherwise have answered any
authenticated caller.

Each of the three fixes has a regression test that was checked by reverting the
fix and watching it fail — four failures for the route table and the binder, four
for the document. That check is the whole reason to trust them, since all three
defects passed a full green suite on the way in.

950 tests green across 16 projects, 14 of them new and no existing test edited.
Zero warnings, format clean, locked restore clean. FluentValidation, JobQueues
and Messaging are in the graph now and none is used.

Not verified: the generated document's response schemas, which differ from
before — FastEndpoints contributes its own Produces metadata. Nothing consumes
the document yet, and MapOpenApi runs only in Development behind the fallback
policy. It needs pinning if ADR 0002's build-time artifacts/openapi/v1.json is
ever built.
2026-07-31 08:39:06 +02:00
jaap-jan d162271a45 Show the host keys this vault has approved
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
Trust was created by the connect prompt and withdrawn from one host's editor, so
a pin for a host that had since been deleted or re-addressed was unreachable
from the interface entirely. It went on refusing connections and nothing in the
application would admit it was there. Two of the four recorded debts were really
this one: leftover pins, and no list to see them in.

A fourth section in the vault column, and the first that adding one has been
cheap for — three edits and two layout tests, which is what #8 and #9 were for.

No editor and no Add, which makes it the only section with neither. A pin is not
something anybody writes: it appears when somebody approves a fingerprint at the
moment of connecting, which is the one place a person can actually check it
against what the operator published. A form for typing one in would be a form
for pasting whatever a man in the middle just offered. So the section exists to
show and to withdraw, which is exactly what was missing.

The fingerprint is shown in full, wrapped, in a monospace line. The only thing
anybody does with one is compare it against a fingerprint an operator published,
and half of one cannot be compared — it can only be glanced at, which is the
habit pinning exists to replace. Nothing here is secret; a host key fingerprint
is published on purpose.

A pin no host in this vault dials is badged rather than hidden or deleted. That
is the leftover the debt was about, and keeping it is still right: the address
may be reached by something without a bookmark, and trust is about the endpoint
rather than the bookmark. The badge is a hint and not a verdict, which is why
nothing acts on it. Matched case-insensitively, because a host name is, and
because a list that called DB.internal unused next to a host saved as
db.internal would be inviting somebody to delete trust they rely on.

Forgetting goes through the same ForgetAsync as the host editor's button, which
withdraws every pin for the address rather than the selected row. Deliberate:
somebody who has stopped trusting a machine has not decided to keep trusting one
of its keys, and a second pin under another algorithm would go on being offered
at the next handshake — which reads as a withdrawal that did not work. The
status line says how many went, and the change is pushed immediately, because
the other machines are the ones still refusing to connect to a rebuilt server.

The list is read through the repository rather than through VaultKnownHostStore,
whose snapshot is shaped for the SSH handshake: one pin per endpoint,
deduplicated, no entity ids. This list has to show duplicates, because a
duplicate is one of the things worth seeing.

Two mutations, both caught: calling every pin dialled (3 tests), and defaulting
the selection to the first row (1) — the same hazard as the credential list,
since Forget acts on the selection.

The selector now holds four buttons in 340 pixels, and TheSelectorIsBigEnoughToClick
measures how much of that they use rather than leaving a fifth section to
discover it as "a button falls outside the window".

936 tests green across 16 projects, 6 of them new. Zero warnings, format clean.

Not verified: how the section looks. It joins the list in outstanding item #7.
2026-07-30 17:44:33 +02:00
jaap-jan f86791e817 Finish revoking a device, instead of half of it
ForgetDeviceAsync stopped this machine unlocking without a passphrase and left
the server's row exactly where it was, so the account went on listing a device
nobody could account for. ADR 0007 recorded that as a deliberate gap needing an
endpoint. This is the endpoint, and the two things that turned up behind it.

DELETE /api/v1/me/devices/{id}. The device row is not the dangerous half: a
kind=device wrap is the user's identity bundle sealed to a key somebody may be
holding, and that is what has to go. It goes on the foreign key's cascade rather
than a second statement, and RevokeDevice_TakesItsWrapWithIt asserts the cascade
rather than trusting the configuration to keep saying so.

Scoped to the caller's own account, which is the only authorisation check there
is. The id is an unguessable v7 GUID, but unguessable is not a permission —
without the scope one user could withdraw another's device key by pasting an id
they saw once, and the victim's next launch would ask for a passphrase with no
explanation. 404 rather than 403 for somebody else's device, so a stranger does
not learn the id exists.

Never refused for being the last device. ADR 0001 makes an enrolled device a
recovery path, so removing the last one does cost the user something — but the
machine being revoked is most likely the one they have just lost, and a server
that argued about it would be refusing the one request that has to work
immediately. The passphrase wrap is untouched either way, which
RevokeDevice_LeavesThePassphraseWrapAlone pins.

--- Two things found on the way ---

Registering twice from one machine left two devices on the account. The server
is idempotent on the public key, but the client generates a fresh key pair every
call and the keystore holds one — so the second registration orphaned a wrap
whose private half had just been overwritten, which is precisely the leftover
this change exists to remove. Registering now withdraws the previous device.
Found by a test that asserted the property and failed.

And the fakes were lying about it. FakeAccountServer's comment claimed the real
service's idempotence while handing back a fresh Guid on every call, which is
invisible until something revokes by id — at which point a test would be
revoking an id the server never issued, and passing. Both fakes now issue one id
per public key and drop the wrap with the device, as the cascade does.

--- Reachable at all ---

ForgetDeviceAsync had exactly one caller and it was a test, so "Stop unlocking
here" now sits in the account bar where "Use Windows Hello here" was. Its own
flag rather than the negation of that one: a machine with no TPM and a machine
that is already registered are both "cannot register", and only the second has
anything to take back.

No confirmation prompt, deliberately. The cost of pressing it by accident is one
passphrase and one re-registration; the cost of a dialog is a moment's
hesitation at the point somebody has realised a machine is in the wrong hands.

Offline it does the local half and says so rather than refusing. Whether this
machine may unlock itself is decided entirely by the local cache and the local
keystore — the unlock path never asks the server — so forgetting here is what
actually revokes, and "you are offline, so this machine will go on unlocking
itself" would be the worst available answer. DeviceRevocation.LocalOnly is what
the interface reports and the status line explains what is left to do.

The local half runs first for the same reason, and the keystore call is the
first thing in the method that can yield: on Windows it raises a consent dialog,
and a dialog wants the thread it was called from. That ordering is currently
load-bearing and shakier than it looks — see the open device-unlock hang.

Four mutations, all caught: dropping the user scope from the server query
(1 test), skipping the stale-device revoke on re-registration (2), skipping the
server call in ForgetDeviceAsync (2), and the earlier version of the client that
never called it at all.

930 tests green across 16 projects, 13 of them new. Zero warnings, format clean.
2026-07-30 17:33:31 +02:00
jaap-jan d17a60e7c3 Stop asking the server to delete things it has never seen
Add a host on a laptop with no network, change your mind, delete it: the outbox
holds a tombstone for a row the server has never heard of, the push answers
Invalid, the change is parked, and the user is left looking at a rejected change
for an item they already deleted and a pending count that will never reach zero.
It applies to all four item types, because they all go through the one generic
repository — the known-host path is only the likeliest way to meet it, since
trust is pinned by connecting and withdrawn from the host editor.

DeleteAsync now drops the queued create instead, when the server cannot be
holding the item. A null expected version means the row is a create — including
a create that has since been edited, because coalescing keeps the original
expected version — so there is no server row and no mirror row, and dropping the
queued change makes the item genuinely gone.

The attempt count is what makes that safe rather than merely convenient. Nothing
sent cannot have landed. A parked row cannot have landed either, because parking
is what the pusher does when the server has refused, so the refusal is the
evidence — and a parked create that the user then deletes could not be got rid
of at all before this: the tombstone replacing it was parked in its turn. What
is left is a create that went out and whose answer was never seen. That one
still gets a tombstone, because the server may be holding the item and a local
drop would strand it there for ever. A refused tombstone is recoverable; an
orphan nobody can see and nobody can delete is not.

Eight tests, and the interesting half is the other direction. A repository that
quietly dropped tombstones would pass a suite written only around the bug and
would lose data on every machine but the one that pressed the button.

Which is not hypothetical, because the mutation pass found exactly that hole in
the first draft of these tests. Removing the expected-version guard left every
test passing: after a sync there is no queued row at all, so deleting a synced
item never reaches the shortcut and proves nothing about it. The way to hold an
unpushed Upsert over an item the server holds is to edit it offline, and
EditingASyncedItemOfflineAndThenDeletingIt_StillQueuesATombstone is the test
that was missing. Without the guard it deletes the item here, leaves it on the
server, and the next pull brings it back.

Three mutations, all caught now: removing the shortcut (5 tests), removing the
expected-version guard (1), removing the attempt-count guard (1). The
Upsert check itself is conservative rather than load-bearing — a queued Delete
with no expected version is not reachable from the interface, and completing one
locally would discard a tombstone that might be needed, so it stays and is not
independently covered.

106 tests green in Client.Sync, 8 of them new. Zero warnings, format clean.
2026-07-30 17:15:51 +02:00
jaap-jan da7462e41f Show one kind of vault item at a time, and let the vault hold passwords
Outstanding items #8 and #9, in one commit rather than two. They are separable
as work and were built in that order, but not as a diff: the section enum has
three members, the one-editor guard has three arms, and the picker offers keys
and credentials from the same list. Reconstructing an #8-only state would mean
hand-writing an intermediate version of VaultViewModel that never existed and
that no test has ever run. One honest commit beats two invented ones.

--- #8, the type selector ---

The column showed two lists and two editors stacked in 340 pixels, and only
just: the key list needed a MaxHeight and had to hide itself whenever its editor
opened, both to stop the host list above it pushing the buttons off the bottom
edge. Credentials would not have fitted at all. It now shows one kind at a time,
chosen by a selector at the top, and both workarounds are gone because a section
owns the whole column.

Three departures from the plan, each with a reason found while building it.

The selector is plain Buttons and a parameterised command, not a TabControl, a
TabStrip or a ListBox. All three of those hold the selection themselves, so a
click moves the highlight before the view model can refuse it — and this column
does refuse, while an editor is open. A selector lit on a section the column is
not showing is worse than the refusal it would be hiding. Buttons carry no state
and cannot disagree with the vault.

The one-editor-at-a-time rule survives with its justification replaced. That
rule was a workaround for the sizing problem above, and sections dissolved it:
the editors are in different sections and only one section is ever laid out.
BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists is now
BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut — the same test, inverted,
because its own comment said that if it ever started passing the rule had become
unnecessary. It has. The rule stays for a better reason: an open key editor
holds a pasted private key in a bound string, and letting the column move on
would leave key material in a form nobody can see, with nothing on screen to say
it is there. A sizing hack became a rule about not hiding a secret from the
person holding it. KeyEditorIsInTheWay and HostEditorIsInTheWay are one
AnEditorIsInTheWay, called by the section switch and by every editor-opening
command.

And releasing the keyboard from the terminal has never worked. MainWindow takes
Win32 focus off the WebView's child window and then calls Focus() on
VaultColumn.KeyboardTarget — and a ListBox is not focusable by default in
Avalonia, which leaves focus to its items. So the call returned false, the
window ended up with nothing focused, and the keystrokes went nowhere: exactly
the state that method's own comment says its second half exists to prevent.
Found by writing the test to assert focus was taken rather than that the right
control was named — the cheap assertion was already passing. Fixed with
Focusable="True" on every list.

--- #9, credentials ---

Credentials have synced since they were added and could not be created. They can
now, and the sync layer needed no change at all: fourth item type, same result,
which is the item-kind seam working as intended.

One picker for all three ways a host authenticates, which is what makes the
illegal combination unrepresentable rather than merely invalid. SshKeyChoice
became AuthenticationChoice carrying an AuthenticationKind, and BuildHost reads
both SshKeyId and CredentialId off that single selection, so a host naming a key
and a credential — which HostSecret.TryValidate refuses — cannot be expressed.
Two pickers would have expressed it and then rejected it at save time. The kind
travels with the id in three places and none is padding: Missing takes it, the
placeholder lookup matches on kind as well as id, and Bound(kind) returns null
unless the selection is that kind. Drop any one and a dangling credential comes
back as a dangling key, which saves as a key binding to an id no key has.

A credential's username had to reach the SSH request, not just its password.
TryBuildCredential returned only the secret and the connect path read the
username off the host, so a stored credential would have gone out under the
wrong account — wrong in a way a server only reports as "authentication failed".
It is now TryBuildAuthentication returning a (Username, Credential) pair.

The no-username refusal moved, and had to. It ran before anything looked at the
binding, which made a credential's username unreachable in the one case it is
most useful: a host somebody never filled a username in for. It is now the last
thing every branch agrees on, so such a host is perfectly usable through a
credential that carries one, and a host with neither still refuses and now says
where to put one.

--- What the measurements cost ---

Ten mutations, all caught. Two are worth naming.

Removing a section's IsVisible is caught by OnlyOneSectionIsOnScreenAtOnce and
by nothing else: two visible sections overlap in the row they share rather than
clip, so every fit test still passes while the column shows one list through
another.

Defaulting the credential selection to the first row is caught by
ReloadingKeepsACredentialSelectionButNeverInventsOne, and the property is a
safety one rather than tidiness — Delete acts on the selection, so a list that
picked a row on every background sync would aim a one-click password deletion at
something nobody chose. The key list has the same property, and its comment
cited a method that has not existed for some time; both now name the delete
command they actually protect.

One test of mine could not fail, and the mutation pass is what found it.
AHostBoundToACredential_SendsItsPasswordAndItsUsername gave the credential and
the host the same username, so it passed whichever one the code read. An
override is only tested when the two values differ.

Two shipped statements went false and were corrected rather than left: the class
remark saying passwords were "not yet" in the vault, and the terminal column's
"Keys are in the vault; passwords are not yet."

That column's hint is now a tooltip on the password box rather than a sentence
in the row, which was measured the hard way — by looking. At the window's 820px
minimum the column gets 480, and a 220px box plus Connect plus any sentence does
not fit; the row has shipped clipped for as long as it has had a hint in it.
That strip is the one part of the window nothing can measure, because MainWindow
cannot be laid out headlessly at all. Extracting it into its own control, as the
vault column was extracted for exactly this reason, is what would fix that, and
is not done here.

911 tests green, 30 of them new. Zero warnings, dotnet format clean.

Seen by a person, which is how the two defects above were found. Still open from
that pass: unlocking with the device key raises its consent dialog and then
never returns, while registering one works — the difference is which thread the
CNG call lands on, and diagnosing it properly is its own change.
2026-07-30 17:08:59 +02:00
jaap-jan 573f5d5668 Keep the device key in the TPM, behind a consent Windows enforces
The last of ADR 0007's three pieces, and it does not implement what that ADR
originally decided — because writing it exposed a flaw in the decision.

The ADR said "a Windows Hello gesture gating a protected blob". That does not
deliver what the rest of the document claims for it: a gate inside the process is
not a gate. A store that showed a prompt and then read a DPAPI blob would be
bypassed by malware that skipped the prompt, read the file and called
CryptUnprotectData itself — which is exactly the attacker the whole decision was
made against, and exactly the reason DPAPI alone was rejected. The presence
requirement has to be a condition of using the key, enforced below the
application, or it is decoration.

So the device key is encrypted to an RSA key created in the Microsoft Platform
Crypto Provider — the TPM — under CngUIProtectionLevels.ProtectKey. Windows
requires consent to use that key, so the prompt is not something this code can be
talked out of showing. Malware can ask for the key; it cannot answer the dialog.
That is strictly stronger than the ADR described, and most of what option D was
being saved for: the wrapping key genuinely never leaves hardware. The X25519
device key still lands in memory to open the wrap, because DSH1 fixes that wrap at
a curve the TPM cannot do — the remaining gap, and now a smaller step than it was.

CngKey is in-box, so this needed no WinRT projection and no Windows target
framework. Which is worth stating plainly because the opposite was planned: the
piece was scoped as "where the Windows TFM lands", and it turned out a platform
guard on one class was enough. Client.App and its two test projects stay on
net10.0.

Two things were measured on real hardware rather than assumed, and the second
changed the shape of the work.

The platform provider works here and holds an RSA key — confirmed by creating and
deleting one before writing anything that depended on it.

And ProtectKey prompts at key *creation*, not only at use. The comment in the
first draft of this file said the opposite, with a confident explanation: sealing
uses only the public half, so it should be silent. It is not. CngKey.Create blocks
on a dialog, because the policy means "protect this key with a PIN" and Windows
asks the user to set that up there and then. Found by writing tests around save
and forget and watching the suite hang for ten minutes waiting for somebody to
type one.

That has two consequences worth knowing before touching this file. SaveAsync is
user-facing code — it belongs on a UI thread, behind a button somebody pressed,
never on a background pass. And almost nothing in the store can be covered
automatically: two tests remain, availability and the empty-blob case, both of
which provably reach no dialog. Disabling the UI policy to make the rest testable
would remove the one property worth having.

The interface offers two things and hides both where they cannot work. "Use
Windows Hello" appears on the unlock screen only when this machine has a cached
wrap and a keystore still willing to release the key; "Use Windows Hello here"
appears in the account bar only when the machine can keep a key and has not
already registered one, so it is spent once used. Absent rather than disabled, in
both cases: a greyed-out button on a machine that never had a TPM reads as
something broken, and the passphrase box beside it is not a fallback — it is the
ordinary way in.

Both unlock paths now share AdoptAsync rather than each opening the known-host
store, building the vault and starting auto-sync. The ordering in there is
load-bearing and a second copy would be a second chance to get it wrong.

The shell's tests drive a fake keystore. Not for speed: the real one prompts on
every save and load, so a suite using it would block forever. What the shell has
to get right is which buttons appear and what happens when one is pressed, and a
fake answers exactly that. It is shared from Client.Session.Tests by source link
rather than reimplemented.

882 tests green, 6 of them new. Zero warnings, dotnet format clean.

Not verified, and not verifiable here: the dialogs. Whether the consent prompt
appears at the right moments, reads sensibly, and returns to a usable window when
declined needs the application run by a person on a machine with a TPM. That is
the remaining half of outstanding item #7, and it is now the only thing between
this feature and being finished.
2026-07-30 15:17:30 +02:00
jaap-jan 1faea42b94 Unlock with this machine's device key, without a passphrase or a network
The second of ADR 0007's three pieces: the seam a keystore plugs into, the wrap
cached where an offline unlock can reach it, and the unlock path itself. What is
still missing is the keystore — UnavailableDeviceKeyStore is what the application
composes for now, so behaviour is unchanged until piece three lands.

IDeviceKeyStore holds exactly 32 bytes, and only because the cache key moved
first. It would have had to hold the local cache key alongside the X25519 scalar —
a second live secret at rest, going stale on every passphrase change — had
7016ce3 not re-keyed that to the identity bundle. ADeviceUnlock_ReadsTheSameCache
ThePassphraseWrote is the test that ties the two commits together: under the old
derivation this session would have opened the identity and then found its own
cache unreadable.

The wrap is cached at registration rather than fetched at unlock, which is the
whole point. The one unlock path that exists to save the user typing must not be
the one that only works online; a laptop on a plane is precisely where a gesture
should help.

Every way this fails returns a status rather than throwing, because none of them
are exceptional — a cancelled fingerprint prompt is the most ordinary thing in
this file. Three statuses rather than one, because the caller says a different
sentence for each: no device registered (the normal state of a machine nobody
opted in on), the machine would not release the key (declined gesture, or a Hello
key invalidated by a PIN reset — deliberately indistinguishable, since the remedy
does not differ), and the key was released and did not open the wrap (a rotated
identity, which will never succeed again and needs re-registering). A keystore
returning the wrong number of bytes lands in the third rather than crashing the
unlock screen.

The subtle defect this could have shipped is in UnlockStore.Apply. That method
runs on every sign-in from a /me response, which knows nothing about this
machine's keystore — so assigning the device columns unconditionally would delete
the wrap on the next launch, and the user's fingerprint would stop working for no
visible reason and no error anywhere. The columns are therefore written only when
the incoming material carries them, with AttachDeviceAsync and DetachDeviceAsync
as the only paths that set them deliberately. Mutation tested: removing the guard
fails RefreshingTheProfile_DoesNotDiscardTheDeviceWrap and nothing else.

RegisterDeviceAsync lives on VaultSession because sealing the bundle is the one
step only an open session can do, and the session is the bundle's custodian.
Everything else arrives as a parameter, exactly as SyncAsync takes its transport,
so the session still knows nothing about how either the wire or the keystore is
implemented. Its steps are ordered so a failure cannot leave a lie behind: the key
is generated, saved locally, and only then registered with the server. A server
row whose private half was never stored is a device that can never unlock and that
the account claims can — worse than not offering the feature at all — so the write
that could produce it happens after the one that prevents it.

ForgetDeviceAsync is deliberately half a job, and says so. It stops this machine
unlocking without a passphrase, which is what a user turning the feature off means,
but the server's wrap row survives and the account will go on listing a device
that cannot unlock. Deleting it needs an endpoint that does not exist yet. Half
with the gap recorded beats a method whose name promises the other half.

The client cache gained two nullable columns and a migration, generated rather
than hand-written this time.

876 tests green, 10 of them new. Zero warnings, dotnet format clean.

Remaining: the Windows Hello store and the unlock-screen UI. That is where the
Windows target framework lands, and where automated testing stops — a gesture
needs hardware and a person, so the last piece is the one that has to be looked at
rather than asserted.
2026-07-30 13:37:35 +02:00
jaap-jan db4a8ed3d3 Let an already-enrolled account register a device key
The first of the three pieces ADR 0007 needs, and the one that was a discovery
rather than a plan. EnrollmentService.AddDevice runs only during enrollment, so
without an endpoint the device-unlock feature would have reached accounts created
after it shipped and no others — which is to say none of the ones that exist. The
code even said so: "the devices endpoint sets it properly when it lands."

POST /api/v1/me/devices takes a name, an X25519 public key and the bundle sealed
to it, and writes a device row plus a UserKeyWrapKind.Device wrap.

Possession is proved by construction, so there is no challenge. The wrap is the
secret bundle sealed to the supplied public key, and only something that has
opened that bundle can produce it. A caller who seals the wrong bytes registers a
device that cannot unlock, which harms nobody else; the server cannot tell the
difference and must not pretend to, because it holds no key that opens either.
That is also why the client must be unlocked to call this at all.

It is the one endpoint in the /me group that requires enrollment, and it says so
itself rather than relying on the group. The group deliberately does not: GET /
and POST /enrollment are how a client discovers it needs to enroll and then does
so, and gating those on enrollment would make enrollment unreachable. Adding the
stricter policy to this route alone means an unenrolled caller is told
"enrollment-required" by the authorization handler rather than getting a 400 about
the shape of a request that was fine.

Idempotent on the public key, and 200 rather than 201 for the reason enrollment
gives: a retry of an identical request returns the same body, so there is no
single moment of creation to point a Location header at. A second row for one key
would mean a device list with a duplicate in it and two wraps to revoke instead
of one. Mutation tested — removing the lookup fails
RegisterDevice_TwiceWithTheSameKey_ReturnsTheSameDeviceAndAddsNoSecondWrap and
nothing else.

That test also found a real defect, in the way these usually surface: two
timestamps that print identically and are not equal. TimeProvider reports
100-nanosecond ticks and PostgreSQL's timestamp with time zone keeps microseconds,
so the first call returned a value that no later read of the row would ever
produce, and the idempotent retry answered with a different timestamp for the same
device. Nothing breaks, which is what makes it worth fixing: the service now
truncates to the precision the column actually holds, so the response is the same
value every time it is asked for. The repo already had a precedent for this class
of thing in KeyLogChain.TruncateTimestamp; it just had not been applied here.

The platform is deliberately not carried on the wire, which leaves
Device.Platform unreported and the stale comment corrected rather than fulfilled.
It would be a display-only field, and a Contracts enum mirroring the domain's
DevicePlatform is exactly the shape of duplication that has produced three
self-consistent bugs in this repository. A device list that wants it can add a
mapping table and a test pinning the two together, which is what the sync entity
types already do.

Its own problem code and exception rather than reusing enrollment's, whose rules
it largely shares. Registering a device is not enrolling, and a client showing
"your enrollment was rejected" because somebody set up a fingerprint reader would
be describing the wrong thing. The validation shares the limit constants —
MaximumWrapBytes, MaximumDeviceNameLength, PublicKeySize — and not the four-line
guards, which would have had to be parameterised over which exception to throw for
less than they cost.

Both in-memory fakes implement it properly rather than throwing: they record the
wrap so a test can assert it arrived, and refuse before enrollment as the real
endpoint's policy does. A fake that answered where the server refuses is a fake
that can make a real bug pass.

866 tests green, 8 of them new. Zero warnings, dotnet format clean.

Still to come: the protector seam with the wrap cached locally so device unlock
works offline, then the Windows Hello implementation and the unlock-screen UI —
which is where the Windows target framework lands and where automated testing
stops.
2026-07-30 13:18:09 +02:00
jaap-jan 7016ce36f1 Key the local cache to the identity, not to the door it was opened through
Groundwork for a device key, and a spec change rather than a feature. ADR 0007
records the decision it clears the way for: a Windows Hello gesture guarding a
protected blob, with the passphrase kept as a permanent fallback.

The reason that decision needed this first is that a device key cannot open a
session on its own. SessionOpener derived two things from the passphrase master
key — the bundle, and the local cache key — and a device wrap is
SealTo(device_x25519_pk), which yields the bundle and never computes a master key
at all. A device unlock could therefore have opened the identity and still not
read the cache it had itself written.

So LocalCacheKey now derives from the bundle: dsh1/localcache/v1 → v2, specified
in crypto.md §3.2. Every wrap that opens a vault ends up holding the bundle, so
every door reaches the same cache.

Extract-and-expand, not expand alone. Everything derived from the master key uses
HKDF-Expand directly, which is sound because an Argon2id output is uniformly
random over its whole length. The bundle's encoding is not — it opens with a
fixed 14-byte label and carries a version, a generation and a timestamp before
reaching any key material — so it needs the extract step to become a pseudorandom
key first.

Two consequences fell out, both improvements and neither the point:

- A passphrase change no longer discards the local cache. The bundle is unchanged
  by a re-wrap, so the cache key is too. Under v1 changing a passphrase silently
  orphaned every cached row and the next launch re-pulled the whole vault.
- Recovery-code unlock is fixed before it ships. It derives a different master key
  from a different secret and a different salt, so under v1 it would have had the
  same defect as the device path, and nobody would have noticed until it landed.

The cache becomes unreadable exactly when the identity is rotated, which is the
correct moment to discard it. Existing caches are discarded and re-pulled on
upgrade — already the specified behaviour for a stale cache, and the reason the
label is versioned rather than reused: a v1 cache must fail to open rather than
decrypt to nonsense.

One stated guarantee got weaker and now says so. crypto.md §10 claimed locking
meant "nothing on disk can be read again without the passphrase." Where a device
wrap exists that is no longer true, and it would have been untrue under either
candidate design — the alternative was storing a copy of the cache key in the
device blob, which is the same door with an extra key lying next to it. The
wording now points at ADR 0007, because what guards the device key is a platform
decision and not a property of this specification.

A golden vector was quietly lying, which is the part worth reading twice. The
"local-cache" entry pinned HKDF-SHA512-Expand over a fixed PRK — a construction
the cache key no longer uses. Regenerating it would have produced a green suite
describing a derivation this code does not perform. It is replaced by a vector
over a bundle whose every byte is pinned: the label, version 1, generation 1, a
fixed timestamp and two recognisable key scalars, all visible in the fixture so a
second implementation can check itself against it. UserSecretBundle.TryDecode is
internal for this, because Create draws fresh randomness and so can never produce
a reproducible input.

Mutation tested, and this one earns its keep: dropping the extract step now fails
CommittedVectors_MatchCurrentImplementation. The vector it replaced could not
have caught that, because it never touched the bundle at all.

One test became false and says so. ARecordSealedUnderAnotherPassphrase is now
ARecordSealedByAnotherIdentity: a different passphrase deliberately no longer
changes the cache key, and TheLocalCacheKey_SurvivesAPassphraseChange pins that.
What must still be unreadable is another user's cache. CacheHarness therefore
generates an identity rather than deriving from a passphrase, and has no
passphrase parameter left — the cache key is not a question about passphrases any
more.

SyncHarness's two simulated machines now derive the same cache key, which is what
keying on the bundle means: they are the same user holding the same identity. They
still have separate cache databases, so nothing is shared between them but the key
that would open either. Both harnesses lost a MasterKey field that existed only to
make a protector.

858 tests green. Zero warnings, dotnet format clean.

Not done: the device key itself. Three pieces remain, and the middle one was a
discovery rather than a plan — EnrollmentService.AddDevice runs only during
enrollment, so every already-enrolled account, which is all of them, needs an
endpoint to add a device wrap while unlocked. The client proves possession by
producing the wrap, so that shape falls out of the crypto. After that: the
protector seam with the wrap cached locally for offline unlock, then the Hello
implementation and the unlock-screen UI, which is where the Windows TFM lands and
where automated testing stops.
2026-07-30 12:46:55 +02:00
jaap-jan c5dec2d68e Measure the vault column instead of arguing about it
Nothing in this repository loaded a .axaml, so the one class of defect this
window has actually shipped — a control arranged past the edge of its container,
where it cannot be clicked — was the one class nothing could catch. The setup
screens rendered sliced once, with their buttons unreachable. The vault column is
the next candidate: 340 pixels wide, two lists and two editors, and the only
thing keeping it from clipping its own Save button at the window's 520-pixel
minimum is a state rule that one editor may be open at a time.

That rule was added on the strength of an argument. This adds an
Avalonia.Headless project that lays real XAML out at a real size and reports
what a user could not reach, and the argument is now a number: with both editors
open the column overflows, so the rule is load-bearing rather than defensive.
BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists is the test, and it says what
to do if it ever starts passing — the column has room, so delete the rule, not
the test.

Two findings arrived by measuring rather than by reasoning, and the first one
changed the design.

MainWindow cannot be shown headlessly at all. Showing it attaches the terminal's
NativeWebView, whose Win32 adapter initialises WebView2 on attach, and WebView2
refuses a non-STA thread — which is exactly why Program.Main carries [STAThread]
and is written down in that comment. A HeadlessUnitTestSession owns its
dispatcher thread and offers no apartment choice, so the whole window is out of
reach at any size. That is pinned as a test asserting RPC_E_CHANGED_MODE by
HResult rather than by message, so a future Avalonia that makes the adapter lazy
will fail it and the harness can be widened.

So the column had to become its own control to be measurable, which is the
extraction the type-selector rework wanted anyway. Keyboard release moved with
it: MainWindow used to call Focus() on HostList by name, and now asks
VaultColumn.KeyboardTarget. The window decides that the keyboard should leave the
terminal and the column decides where it lands — which is the seam the rework
needs, because once the column shows one list at a time, "which list owns the
keyboard" is a question only the column can answer.

The second finding is the way this kind of test lies quietly. The hint class
lived in MainWindow.Styles and carries TextWrapping. A Window's styles reach its
whole tree, so nothing about the application depended on where it lived — but a
control laid out on its own loses them, and every hint paragraph would have
measured as a single line. The harness would have passed while measuring heights
that were all too small. The three shared classes now live in App.axaml, which
changes no rendering and makes the measurement honest.

The detector is calibrated in both directions, because a clipping detector that
never fires reads as a guarantee: a deliberately clipped Save button is caught by
name, and a list longer than its viewport is exempt. Scrolling is how a list is
supposed to handle more rows than fit, and without that exemption the host list
would fail the moment it had content. It also mis-fired once and the rule is
narrower for it — an empty ListBox is zero pixels tall and correct, so "arranged
with no size" now applies only to controls the theme gives a height to.

Skia rather than the headless drawing stub, deliberately. The stub's font manager
invents glyph metrics, and text height is an input to every stacked panel in this
column, so measuring against it would produce numbers that are self-consistent
and unrelated to the application.

A separate test project rather than more tests in DodoSSH.Client.App.Tests.
Avalonia's application, dispatcher and platform are process-global singletons
initialised once, and that project's identity is the shell's state machine
without Avalonia — the whole reason sign-in is a delegate. The fakes needed to
reach a real unlocked vault are shared from DodoSSH.Client.Session.Tests by
source link: a project reference would make one test project a library of
another, and a copy would be a third implementation of the same decision table
drifting from the other two.

855 tests green, 10 of them new. Zero warnings, dotnet format clean.

Not done, and this is groundwork rather than the item itself: the type selector.
The column still holds both lists at once, so a third item type would still
recreate the defect the one-editor rule works around. What is different is that
the rework can now be checked instead of eyeballed — including the claim it is
being made for, that one editor at a time stops being a runtime rule and becomes
a fact about what is in the visual tree.

What this harness will never catch is the terminal's native child window
compositing over Avalonia content. That is a Win32 property of a real window, no
headless surface reproduces it, and it is the reason the WebView is collapsed
rather than covered.
2026-07-30 11:34:09 +02:00
jaap-jan 211eba0666 Keep host key trust in the vault, and make it withdrawable
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
A fingerprint approved once is now approved on every machine and survives a
restart, because host key trust is a vault item type rather than a dictionary
that dies with the process. InMemoryKnownHostStore was what shipped, so the user
was asked to verify a fingerprint on every single connection — which is the gap
most likely to train somebody to click through the one warning that actually
matters. A warning that appears when nothing is wrong teaches that nothing is
ever wrong.

The fourth item type, and like the third it cost no sync logic: a row, an EF
configuration, a migration, a server kind; a secret, a codec, a merge, a cipher,
a repository facade and a session property. One row in the client registry. The
reconciler, the mirror, the repository, the outbox and the pull filter were not
touched. SyncEntityType.KnownHostKey and AadResourceType.KnownHostKey were
already reserved, so neither the contract nor docs/crypto.md changed.

One item per (host, port, algorithm), because a server legitimately offers
several host keys and which one gets negotiated is not ours to predict. Pinning
per endpoint would make an algorithm change indistinguishable from an attack.

The label is derived rather than stored, which is the one place this type
departs from the other three. A user never names a pin — there is nothing to
name it after but the three fields it already has — and a stored label is a
second copy of data that can disagree with the first after a merge. Relabel
returns the secret unchanged, and says why.

The store answers the handshake without touching the disk. SshNetConnectionFactory
calls FindAsync from inside SSH.NET's synchronous HostKeyReceived event, over
.GetAwaiter().GetResult(), which cannot be avoided; doing SQLite I/O plus an AEAD
open per lookup there would put the handshake behind the cache. So decryption
happens in OpenAsync and RefreshAsync — on unlock and after each sync pass,
exactly where the host and key lists already reload — and FindAsync is a
dictionary read under a lock with no await inside it.

That snapshot is where the one real bug in this change lived. Install originally
merged the live pins over the freshly loaded snapshot, to protect a TrustAsync
that had landed while the read was in flight. It would also have resurrected
every pin the user had just forgotten, and stopped a withdrawal made on another
machine from ever taking effect — the store would have healed the deletion back
into existence on every refresh. Replacing wholesale and discarding the read
instead is correct because writes are the rare case: every write bumps a
generation counter, and a refresh whose stamp is stale throws itself away rather
than winning. Nothing found this but reading the method again; it is the kind of
mistake that passes every test written before it, because the test that catches
it is the one the bug tells you to write.

Forgetting is new, and persistence is what made it mandatory rather than
convenient. A mismatch is a hard refusal with no way to continue — deliberately,
and that stays — so pinning a key permanently is also a way to make a
legitimately rebuilt server permanently unreachable. Before this change the pin
died at exit and the problem solved itself; now it does not.

ForgetAsync drops every algorithm for an endpoint, and it is reachable from the
host editor rather than from the warning. Putting it on the mismatch banner would
have made it two clicks from "this may be an attack" to "connect anyway", which
is the affordance the hard refusal exists to deny. The banner already promised
the key could be removed in the host's settings; that promise is now true and
points at the button.

Trust recorded on another machine becomes visible at the next sync pass, not
immediately, and that is a decision rather than an oversight. The failure it
produces is a first-contact prompt for a host a colleague approved a minute ago:
answerable, and self-correcting on the next pass. The opposite trade — polling
the vault on the handshake thread to close a one-minute window — buys nothing
and costs the property above. The dangerous direction is not reachable at all: a
pin recorded here enters the snapshot as part of recording it, so a refresh can
never discard a local trust decision.

The server learns nothing, and this is the item type where the temptation was
real. A plaintext host column would let a known-hosts screen sort and page
without decrypting anything, and it would hand the operator the map of every
user's estate — assembled, as these things are, out of facts that are each
individually harmless. A host row concedes an address only when relay is
switched on and the database refuses to store one otherwise (ADR 0004); there is
no equivalent excuse here. The table has no column to put one in, and the EF
configuration says so where somebody adding it would be standing.

Two things about the migration in this commit are worth knowing, because both
came out of getting it wrong.

It was hand-written first, including its .Designer.cs, and that version is not
what is here. Verifying it turned up something that had been quietly assumed:
Migration_AppliedCleanly_WithNoPendingModelChanges does not check the model
snapshot. It asserts that migrations applied and that none are pending, which a
wrong snapshot satisfies perfectly — the snapshot only matters as the diff base
for the *next* migrations add, so an incorrect one passes the whole suite and
corrupts the following migration instead. The real check is to generate a
throwaway migration and confirm its Up and Down come out empty. They did, and
the generated designer was byte-identical to the transcribed one across all 1255
lines, so the hand-written work was in fact correct.

Then dotnet ef migrations remove --no-build deleted the wrong migration. With
--no-build the tool reads the previously compiled assembly rather than the files
on disk, and the probe had just changed which migration was last, so it removed
AddKnownHostKeyItem and reverted the snapshot. That turned out to leave exactly
the right diff base, so the migration here is EF's own output rather than a
transcription — a better outcome than the one that was interrupted, arrived at
by accident. Never pass --no-build to migrations remove.

Mutation tested, all three sabotages detected: dropping the algorithm from
KnownHostIdentity.For, merging instead of replacing in Install, and pointing
KnownHostKeyCipher at PortForward — which is what a cast from the wire enum's 10
would silently produce. Each is caught both by an assertion about the mechanism
and by a behavioural test that never mentions it; the resource-type sabotage is
caught by the table from d10a38d and nothing else, which is what that table is
for.

The end-to-end slice now approves the real sshd's host key through the vault,
pushes it, and reads it back on the second simulated machine — including a check
that the server learned no address, and that the second machine answers null for
an algorithm never offered.

845 tests green. Zero warnings, dotnet format clean.

Three things are deliberately not fixed. A tombstone queued over a create that
was never pushed is refused by the server as Invalid and parked; that is
pre-existing for all four item types, and the fix belongs in
VaultItemRepository.DeleteAsync rather than here. Deleting a host, or changing
its address, orphans its pins — both are correct as trust decisions, since a pin
describes an endpoint and not a bookmark, but nothing surfaces the leftovers.
And there is no interface listing pins at all: trust is created at the connect
prompt and withdrawn in the host editor. A known-hosts list is where the orphans
would become visible, and it wants the vault column rework first, for the same
reason the credential editor does.
2026-07-30 11:00:39 +02:00
jaap-jan d10a38d8e6 Pin every cipher's AAD resource type from one table, not one test each
Mutation testing found that pointing CredentialCipher at
AadResourceType.Vault passed the entire suite. Every credential test compared
the cipher against itself — round trips, cross-type refusals, two-machine sync —
and all of those stay true when both halves of one cipher are wrong together,
because Seal and TryOpen share the constant. A password sealed under the resource
type for a vault encrypts cleanly, decrypts cleanly, syncs cleanly, and violates
docs/crypto.md in a way nothing surfaces until another implementation refuses the
item. By then the AAD is frozen into stored ciphertext and only clients can
re-encrypt it.

This is the third time that hole has appeared in this file, and the second time
mutation testing rather than review is what found it. So the fix is structural
rather than another hand-written test: one table of wire type to resource type,
a theory that seals a sample through each cipher and opens it with the resource
type the table names — never the one the cipher holds — and a guard asserting the
table covers ItemKinds.SyncedTypes. A fourth item type can no longer be added
without pinning its resource type: the coverage test fails, and the sample switch
throws with an explanation.

The two per-cipher tests it replaces said the same thing for hosts and keys, so
nothing is lost and the credential row is no longer something someone has to
remember.

Verified by re-running the mutation matrix. All seven sabotages are now detected:
the credential merge dropping its redaction, the key/credential exclusivity check
disabled, the schema version ladder flattened so a key-bound host claims the
credential version, a credential sending the server an empty fields record
instead of none, CredentialKind claiming to be a host, CredentialCipher sealing
under the wrong resource type, and the credential noun reading "host". Two of
those were unproven before this run — one because the earlier sabotage did not
compile, and one because it was genuinely undetected.

Sync.Tests 88/88, Domain.Tests 117/117. Zero warnings, dotnet format clean.
2026-07-29 21:17:36 +02:00
jaap-jan e24012b039 Sync credentials as a vault item type, and bind one to a host
Closes the largest remaining M1 gap in the data layer: a username and password
can live in the vault, sync between machines, and be named by a host as how it
authenticates. What is not here is the interface for creating one — see the end
of this message.

The third item type, and the first one that cost almost nothing to add. Server:
a VaultCredential row, an EF configuration, a migration, and a CredentialKind.
Client: a secret, a codec, a merge, a cipher, a kind, a repository facade and a
session property. No new reconciliation logic, no change to the sync engine, no
client cache migration. That was the whole point of the item-kind seam, and this
is the evidence it holds.

The narrowest type of the three on plaintext, and not for symmetry. A host has a
deliberate concession — the relay needs an address it can resolve. A key has a
fingerprint, public by nature, which this client still declines to send. A
password has no part that is safe to expose: not its length, not a hash, not a
hint. So CredentialKind refuses every plaintext field there is, hydrates none,
and the table has no column to put one in.

HostSecret.CredentialId is the password counterpart of SshKeyId, and the two are
mutually exclusive. SSH itself would happily try a key and fall back to a
password, but a host naming both leaves "how does this authenticate?" without a
single answer — the interface, the connect path and the user would each be free
to guess differently. TryValidate refuses it. One consequence was not
anticipated: "a full host" stops being a coherent idea, which is what broke
AFullHost_RoundTrips and is now written into that test.

The schema version became a ladder rather than a maximum: credential-bound is 3,
key-bound is 2, neither is still 1. Adding credentials therefore does not drag
every key-bound host in every vault onto a version that clients understanding
keys perfectly well would refuse to edit. A test pins exactly that, because it is
the property the whole content-dependent-version rule exists to provide, and the
obvious implementation would quietly lose it.

Two tests had become false and said so:

- Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch used Credential as its
  example of a type this server does not implement. It now asks the server's own
  registry what is still missing, so it cannot go stale again, and skips with a
  reason if that set ever empties.
- ThePullFilterNamesEveryTypeThisBuildSynchronises pinned the exact list, which
  is what it is for.

Also fixes ten nullable warnings — eight in SyncEndpointTests, two in a test file
added earlier today. Neither set was introduced here; both were invisible until
an unrelated change forced their project to recompile, which means the
zero-warning claims made earlier in this work only ever covered what happened to
be rebuilt.

777 tests green. Zero warnings, dotnet format clean.

Not done, and deliberately: the credential interface. The vault column is 340
pixels wide and already holds two lists and two editors, kept from clipping its
own buttons at the window's minimum height only by the one-editor-at-a-time rule
added earlier today. A third list and a third editor would recreate that defect
rather than avoid it, so the column needs a shape decision first. Credentials
sync; they cannot yet be created in the interface.
2026-07-29 21:09:08 +02:00
jaap-jan 70b3290a77 Bind an SSH key to a host instead of picking one per connection
A host now names the key it authenticates with, or none, as a field in its
encrypted payload — so the choice follows the host to every machine rather than
being made again each time somebody connects. The per-connection "Use key"
switch it replaces was a stopgap for not having this, and keeping both would
have left two mechanisms answering one question.

This is the first payload schema version bump, and it does not work the obvious
way. A host is written at the *lowest* schema version that can represent it: one
that binds a key is written at 2, one that does not is still written at 1, byte
for byte as it was before the field existed. The version is what makes an older
client refuse to edit an item, so stamping 2 unconditionally would mean
upgrading a single machine and renaming a single host made that host uneditable
on every machine that had not upgraded yet. Confining the cost to the hosts that
actually use the field is the difference between a team noticing a bump and a
team being blocked by one. HostSecretCodec states the rule so the next field
added follows it, and a test pins the version-1 bytes against a literal rather
than against the codec, because the claim is about history: every host already in
every vault has to re-encode to what it encoded before, or the first sync after
an upgrade would push the whole vault as changed.

A binding is an item id, not a copy of the key — a second copy of a private key
is one that goes stale — which means the reference can dangle when the key is
deleted on another machine. Both places that meets are handled the same way, by
refusing rather than falling back:

- Connecting to a host whose key is gone is refused outright. A host somebody
  deliberately set up for key-only access must not quietly start offering a
  password.
- Opening such a host in the editor keeps the binding, selected, labelled as
  missing. The quieter version of the same failure is someone editing the port
  and saving, silently converting the host to password authentication with
  nothing ever having said so.

Two things this found by being falsified:

- The merge was untested for the new field, and "just take the server's value"
  passed the entire suite — a local binding change would have been discarded with
  no conflict recorded. HostSecretMergeTests already had a test written for
  exactly this class of omission; it simply had not been extended.

- Adding a nullable field exposed a defect in HostSecretMerge.Field: it
  short-circuited when the discarded value was null, so the formatter never ran
  for the one case where null is a value rather than an absence, and a field
  whose absence has a name could not report it. Now the formatter always runs,
  and "no key" appears in the conflict log where an empty string used to.

Also fixes eight nullable warnings in SyncEndpointTests left by the server-side
SSH key commit, which had omitted the null-forgiving operator the rest of that
file uses. They were invisible until an unrelated change forced the project to
recompile.

The end-to-end slice now binds its host to its key, so a schema-version-2
payload goes through the real API, the real PostgreSQL and back out on a second
machine.

745 tests green. Zero warnings, dotnet format clean.
2026-07-29 20:42:51 +02:00
jaap-jan e3fd3e1728 Sync and authenticate with SSH keys on the client
Completes the client half of SSH keys: they sync alongside hosts, appear in
their own list, and can be selected to authenticate a connection instead of
typing a password.

The reconciler and the repository were Host-typed throughout, so the choice was
to generalise them or to keep a second copy per item type. Generalised, because
ItemReconciler's whole premise is that the pull and the push paths must answer
the same collision the same way — two copies would drift the first time one of
them was fixed. What is genuinely per-type now arrives through
IItemKind<TSecret>: the cipher, the merge, the plaintext columns, and the noun
to use when telling a person what happened to their item. Generic where the
server's IItemKind is not, and for the reason that reverses there — the client
needs the concrete type, because it merges field by field.

The pull filter is derived from the same registry that builds the reconcilers.
That is the specific failure being designed out: an item type that encrypts,
merges and lists perfectly and is never once requested from the server, so it
works on the machine that made it and exists nowhere else.

No client cache migration. The item table's primary key and the outbox's unique
index already carry the entity type, and AadResourceTypes already mapped SshKey
— so a host and a key may share an id and never see each other's rows, which
SshKeySyncTests now arranges deliberately.

A key hands the server nothing in plaintext. There is a public_key_fingerprint
column and it would be accepted; leaving it null is deliberate. A fingerprint is
not secret but it is a stable identifier for a key pair, so filling it would let
an operator tell which of their users hold the same key and correlate one across
vaults, for a column nothing reads. The design allows itself one plaintext
concession — the relay address, which the relay cannot work without — and this
is not that.

A key is chosen per connection rather than bound to a host, which works the way
ssh -i does. Binding one needs a field on HostSecret and therefore a payload
schema bump, which makes every host written afterwards read-only on an older
build; worth doing deliberately rather than as a side effect of adding keys.

Three things this found, all of them by being falsified rather than by review:

- Making the reconciler generic silently turned a record comparison into
  reference equality, because == on a type parameter is not value equality. The
  effect would have been a conflict recorded on every pass for an unacknowledged
  create that had in fact landed. Sabotaging the fix left all 73 tests passing —
  nothing covered that branch — so ConflictMatrixTests now has
  AnUnacknowledgedCreateThatDidLand_IsDroppedQuietly, which fails without it.

- A test asserting that a blank passphrase reaches SSH.NET as null was vacuous:
  it exercised the editor, not the credential path, and passed with the guard
  deleted. Resolved by making SshKeySecret.Passphrase normalise an empty string
  to null, so there is one spelling of one state — which also keeps two clients
  from producing different payload bytes for an identical key. That exposed a
  wider gap: SshKeySecret, its codec and its merge had no direct unit tests at
  all. They have 25 now.

- The reason first given for that normalisation was false. It claimed SSH.NET
  rejects a passphrase supplied for an unprotected key; measured against a real
  sshd it ignores it and authenticates anyway. Corrected everywhere it was
  stated and recorded in docs/platform-flags.md. The same test file also closes
  a real hole: SshPrivateKeyCredential had never been exercised against a
  server, because the existing key test builds SSH.NET's auth method directly
  and bypasses the path a vault-held key actually takes.

Only one editor may be open at a time. Both sit in the same 340-pixel column as
Auto rows and their heights together exceed it at the window's minimum size, so
two open editors put the lower one's Save and Cancel past the bottom edge — the
same failure this window already shipped once with the setup screens. Expressed
as a state rule because that is the only form of it this repository can check:
nothing here loads a .axaml. The refusal keeps what was typed, since in the key
editor that is a pasted private key the user may have nowhere else.

The end-to-end slice now carries a key as well as a host, so both item types go
through the real API, the real PostgreSQL and the real crypto in one pass — the
three hand-kept mappings between enums that do not line up are the reason that
is worth doing rather than trusting the unit suites.

735 tests green, including the container-backed SSH and end-to-end suites. Zero
warnings, dotnet format clean.
2026-07-29 20:27:23 +02:00
jaap-jan 586cb303d5 Merge branch 'claude/gallant-brahmagupta-1f8244'
ci / build and test (ubuntu) (push) Has been cancelled
ci / build (windows) (push) Has been cancelled
Writes down that locking the vault leaves shells running, and shows the count on
the unlock screen rather than leaving it to be inferred.

Conflict resolution:

- ShellFlowTests' fixture keeps main's FakeSshConnectionFactory. The branch added
  an IdleSshConnectionFactory for exactly what main's fake already does — a shell
  that is open, silent and never closes on its own — so FakeSshConnections.cs is
  dropped rather than merged, leaving one fake SSH stack in the suite instead of
  two that would drift apart.
- MainWindowViewModel and TerminalWorkspace: both sides added their own members,
  so both are kept.
- TerminalWorkspaceTests was added by both branches, with the renderer gate on
  one side and session lifetime on the other. Merged into one class over one set
  of helpers; the gate tests now use FakeConnectionFactory rather than an
  NSubstitute stub, since the suite already has the fake. gallant's polling
  Timeout constant is PollTimeout, which no longer reads as the renderer's.
- platform-flags.md keeps main's measured focus section and drops the short
  "nothing hands the terminal keyboard focus" entry the branch still carried,
  which that section supersedes.

One genuine disagreement between the branches, left visible rather than
flattened: this branch measured that a collapsed WebView cannot be typed into and
attributed it to a hidden WS_CHILD window being ineligible for keyboard focus,
while main's focus work measured Win32 focus still held by that hidden window and
added a lock path that moves the keyboard off it. Both results stand; the
mechanism sentence now defers to the focus entry, which makes the input barrier
something the lock path maintains rather than something the platform guarantees.

Full suite green, including the container-backed SSH tests.
2026-07-29 15:46:41 +02:00
jaap-jan 74341d41e0 Merge branch 'claude/distracted-ritchie-53fc70'
Bounds the renderer wait, so a WebView2 that never initialises reports itself
instead of hanging Connect with the busy flag stuck.

Conflict resolution, all of it in the App test suite, which main had changed
under the branch when sleepy-chebyshev landed:

- The workspace fixture keeps main's fake SSH factory and its FakeRenderer-aware
  page, and takes the branch's RendererTimeout on top. One second rather than
  the branch's 250 ms, because the timeout now also bounds FakeRenderer's own
  wait for the attach it just made.
- FakeRenderer arrived on main after the branch was cut and still called the
  no-argument WaitForRendererAsync. Both sides merged cleanly and left the build
  broken; it now passes its own token.
- ConnectingWithNoRenderer's remark claimed the suite never starts the workspace
  and never attaches a renderer. Both are false here, so it now says what is
  true of the test: it is the one connect test that attaches no renderer.
2026-07-29 15:40:25 +02:00
jaap-jan 9270d0cba5 Merge branch 'claude/sleepy-chebyshev-cda68d' 2026-07-29 15:35:02 +02:00
jaap-jan 0331ce8f33 Commit Rider's VCS directory mapping
The one file left untracked in the working tree, and it belongs in the repo
rather than in .gitignore: the ignore rules here already follow JetBrains'
own pattern, which excludes per-user state — workspace.xml, tasks.xml,
dataSources, shelf — and keeps shareable project configuration. vcs.xml is
the latter. All it says is that the project root is a Git checkout, which is
true for everyone who clones it, and having it present stops Rider prompting
each new checkout to add the mapping by hand.
2026-07-29 15:34:46 +02:00
jaap-jan c4dbd85da0 Add the client's SSH key model, codec, merge and cipher
The client can now seal and open an SSH key item. Nothing consumes it yet —
the repository, the sync engine's per-type handling and the UI come next — but
this is the layer everything above it depends on, and it is the layer where
the crypto has to be right.

SshKeySecret holds the private key as an ordinary string, deliberately, and
says so: a .NET string cannot be wiped, so the material lives until the GC
reuses the memory. libsodium's guarded memory was considered and rejected
because the passphrase protecting the key, the password on the next item and
the JSON the codec just parsed are all strings on the same heap — protecting
one field among them reads as security and buys nothing. What the design does
give is that the key never reaches the disk in plaintext, never reaches the
server at all, and is handed to SSH.NET through a MemoryStream so there is no
temporary key file to leak.

Validation refuses a public key by name. ssh-keygen writes two files whose
names differ by four characters, and pasting the wrong one otherwise produces
a vault item that looks fine and fails at connection time with an
authentication error that says nothing about which file you chose.

The merge redacts the private key and its passphrase from the conflict log.
A host conflict shows both values so the loser can be put back; doing that for
a private key would write the discarded key into a log that is designed to be
read rather than used and is deliberately retained after acknowledgement. Two
different private keys are not something anyone reconciles by reading them
side by side.

And the lesson worth recording, because it nearly shipped: the first version
of AadResourceTypeTests proved nothing. It checked that a key payload does not
open as a host and vice versa — true however both ciphers are misconfigured,
because Seal and TryOpen share one constant, so changing it changes both and
the round trip still works. Sealing every private key as if it were a vault
passed all twelve tests. The tests now open a sealed payload independently
through ItemKeys with the resource type named out of band, and that does fail
under the same sabotage. A test that only compares an implementation against
itself cannot catch a self-consistent mistake.

The trap it defends: SyncEntityType.SshKey is 3, AadResourceType.SshKey is 6,
because the crypto enum also carries None, User, Device and Vault ahead of the
item types. A cast between them is a specification violation that encrypts
cleanly and would only surface when another implementation refused the item.
2026-07-29 15:33:28 +02:00
jaap-jan d459dac600 Stop a dead WebView2 hanging Connect with the busy flag stuck
VaultViewModel.ConnectAsync awaited TerminalWorkspace.WaitForRendererAsync
with no timeout and no token, and RunAsync clears IsBusy only after the
work returns. Whether the renderer attaches at all depends on a runtime
this application does not install: with a missing or policy-blocked
Evergreen runtime, or an AppContainer that cannot reach loopback, the
socket never arrives — so Connect never returned, the window stayed
disabled on "Connecting…" for the rest of the session, and nothing on
screen said why. Left out of 0500e43 to keep that change focused, and
recorded in docs/platform-flags.md as worth fixing on its own merits.

The gate itself is unchanged and has to stay: TerminalDataPlane.SendAsync
drops frames when no renderer is attached rather than queueing them, so a
session opened before the renderer arrives loses its SessionOpened frame
and then streams output at a terminal that was never created. Only the
wait changed — RendererAttached.WaitAsync(timeout, cancellationToken),
with the command's own token threaded through.

Fifteen seconds, on TerminalWorkspaceOptions.RendererTimeout. Attaching is
normally near-instant, since WebView2 starts with the window and the page
has usually attached while the passphrase was still being typed, but a
first run on a cold profile creates a user-data directory and starts a
process tree of some thirty-five processes first, which on a loaded
machine is seconds rather than milliseconds. A renderer that will never
attach will not attach however long the wait is, so being generous costs
only how long a broken runtime takes to say so, while being tight costs
telling someone their runtime is broken when it was merely slow.
Injectable because both new tests would otherwise sit out that budget.

The timeout is caught in VaultViewModel rather than left to RunAsync's
generic handler, because TimeoutException.Message is "The operation has
timed out" — which sends someone looking at their network or their host.
The status now names the WebView2 runtime and says to install it.

TerminalWorkspaceTests covers the half that was missing: the wait gives up
(329 ms against a 250 ms budget) and obeys its token (2 ms against a
five-minute one). Before the bound, the first of those would have hung
rather than failed. ShellFlowTests never starts its workspace, which from
the view model's side is indistinguishable from a WebView2 that failed to
initialise, so it asserts that the status names WebView2 and that IsBusy
is cleared; changing the catch to another exception type makes it fail
with "The operation has timed out.", so neither assertion is vacuous. The
success path is untouched and still covered end to end by
TerminalEndToEndTests against a real sshd container, which now passes the
test's cancellation token.

One byproduct: the doc comment on WaitForRendererAsync carried two
double-encoded em dashes, fixed now that the block is rewritten.
2026-07-29 15:26:53 +02:00
jaap-jan e93acc856f Sync SSH keys as a vault item type, over a shared write path
The private key now lives in the vault as ciphertext, syncs between a user's
machines, and is stored on the server so it can later be shared — sharing
itself needs M3's signed grants; this is the storage that makes it possible.

More was already reserved than expected: SyncEntityType.SshKey,
CryptoSpec.AadResourceType.SshKey, ChangeEntityType.SshKey,
SyncPlaintextFields.PublicKeyFingerprint, and SshPrivateKeyCredential wired
through PrivateKeyFile over a MemoryStream so a key never touches disk. The
frozen contract and crypto spec needed no change at all.

What was missing was the server. Rather than copy the push path per item type
— version check, change-log append, exactly-once receipt, advisory lock — it
is now written once over IVaultItem, with everything type-specific behind
IItemKind: which table, which plaintext columns, and what those columns must
satisfy. Ten copies of that logic by M5, with a fix applied to nine, is the
outcome this avoids. The refactor landed first with no behaviour change, so
all 66 existing Host tests were the regression net, and they stayed green.

An interface rather than a base class, deliberately: EF Core maps an
inheritance hierarchy when it can see one, so a mapped base would quietly
become a table-per-hierarchy discriminator across item types — the very
arrangement per-type tables exist to avoid.

ssh_key mirrors host and pointedly has no relay trio. That is the argument
for separate tables rather than one wide item table: the columns a host needs
are columns a key must never have, and a shared table could only make them
nullable and trust the code. A key carrying a relay target is refused with a
reason rather than silently dropped.

A key hydrates PlaintextFields as null, not an empty instance — the
difference is visible on the wire, because an all-defaults instance still
serialises "relayEnabled": false and invites a reader to believe the setting
exists and is off. It has none.

Two things now defended by tests rather than by comments. Each kind states
its own ChangeEntityType instead of casting: the two enums agree numerically
but do not even share member names (Host against SshHost), and filing key
changes under the host type is silent sync corruption — sabotaging it fails
three tests. And EntityTypeAlignmentTests asserts the two enums stay aligned
in both directions and in count, which nothing did before.

The client half is next: SshKeySecret, its codec and merge, the cipher, a
repository, and the UI. Note for that work — SyncEntityType.SshKey is 3 while
AadResourceType.SshKey is 6, so a cast between them would seal key ciphertext
as a vault and nothing would fail.
2026-07-29 15:14:06 +02:00
jaap-jan c6fc19bbbd Sync the vault automatically instead of only on a button press
Three triggers: once when the vault opens, straight after any local change,
and every minute while it stays open. The Sync button stays, because someone
just handed a credential wants to know now rather than within the minute, but
nothing depends on it being pressed any more.

A background pass is deliberately not the button's code path. Routing it
through RunAsync would raise the busy flag every minute — disabling Connect
and Save for the duration — and repaint the status line over whatever the user
was reading. So it is quiet: the status changes only when a pass actually
moved an item or produced something needing attention, and a pass is skipped
outright while a command is running rather than queueing behind it. Both
guards are covered; removing either fails a test.

A shared semaphore serialises every pass, taken with a zero timeout rather
than awaited — a pass arriving while another runs has nothing to add by
waiting, and queueing them would turn a slow server into a backlog of
identical work.

Failures are swallowed, which is right in exactly this one place: a laptop
closed all afternoon would otherwise replace the status line with a socket
error once a minute. It is quiet rather than hidden — the account bar already
shows when there is no connection, and pressing Sync reports the real reason.
What earns that is the outbox: a test proves a change left queued by a failed
pass is still sent by the next sync, so quiet never means lost.

Two existing tests asserted the opposite behaviour — that a save queued and
pushed nothing until Sync was pressed — and were rewritten rather than
deleted; the local-first guarantee they were really protecting is that the
list updates with no server, which the offline test still covers.

Two things the tests caught in my own work. ReloadAsync had to be split out
of LoadAsync because rebuilding the list repainted the status line
unconditionally, which made "the background pass is quiet" false on the one
path that mattered. And the yields-to-a-command test was vacuous as first
written: saving pushes, so there was no pending change left and the assertion
held with the guard deleted. It now fails the automatic push first to arrange
a real queue.
2026-07-29 14:53:19 +02:00
jaap-jan 5a899afd78 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.
2026-07-29 14:44:02 +02:00
jaap-jan dbddbcd711 Hand the terminal the keyboard on connect, and take it back on lock
After a successful connect the first keystrokes went to the shell's UI rather
than the remote shell. The page's own term.focus() focuses the textarea inside
the document, which does nothing while the window's keyboard focus is still on
the Connect button, so the terminal had to be clicked before it would accept
anything.

The obvious guess about the fix — that reaching a native child window needs
SetFocus through P/Invoke — is backwards, and measuring it first is what kept
this small. NativeWebView overrides Focusable to true and its OnGotFocus calls
the adapter's Focus(), which on Windows is
ICoreWebView2Controller::MoveFocus(PROGRAMMATIC). So a plain Avalonia
Terminal.Focus() really does move Win32 focus into WebView2. Measured in a
standalone harness with no DodoSSH code, on the same 340,* grid as the shell,
reporting GetFocus() and the page's own document.hasFocus() at each step: focus
lands on the Chrome_WidgetWin_1 child and the page reports hasFocus: true.

It is the return trip the package does not implement. OnLostFocus calls the
adapter's ResignFocus(), and on Windows that method body is empty, so Avalonia's
focus and Win32's diverge: after textBox.Focus() the focused element is the text
box while the keyboard is still on WebView2 — a caret that silently receives
nothing. Window.Activate() and Window.Focus() were both measured and neither
recovers it, so the hand-back is a SetFocus on the top-level, in
Views/NativeKeyboardFocus.cs. A real mouse click does recover it, because
Avalonia's window sets focus on pointer input, which is why this is invisible to
anyone who clicks before typing.

That turned up a worse defect than the one being fixed, and it shipped in
0500e43. Collapsing the WebView does not release the keyboard: focus stays on
the hidden holder — measured held by a window reporting visible=False — while
Avalonia's focused element becomes (none). So a user who had clicked the
terminal and then pressed Lock got an unlock screen that swallowed the
passphrase. Locking now hands the keyboard back and focuses that box.

Ctrl+Shift+F6 is the way out for someone using only a keyboard. It has to be
handled in terminal.js and posted to the host as a web message, because once the
child window owns Win32 focus Avalonia receives no key events and no KeyBinding
could fire; the package also subscribes MoveFocusRequested and discards it, so
there is no Tab-out to lean on. Not Escape, which vim alone rules out, and not a
bare F6, which TUIs bind — Ctrl+Shift is the range terminal emulators
conventionally keep for themselves and never forward to the remote. Verified
rather than assumed: the posted string arrives verbatim in Body, and the chord
reaches the page as F6 with both modifiers.

Order matters and is now recorded. Focus() on a collapsed control is a measured
no-op and is not replayed when it is revealed, so focus survives a lock/unlock
cycle only because a session can be opened solely from an unlocked vault, which
is what reveals the control in the first place.

The view models still reference no view. VaultViewModel raises SessionOpened on
the success path only, the shell forwards it as TerminalSessionOpened through the
generated OnVaultChanged hook so unlock, lock and dispose all attach and detach
in one place, and the view holds the whole focus policy. An event rather than a
bound flag because connecting a second host while one is open has to move focus
again, and no state change describes that.

Three tests, and what they do not cover is the point. They cover the plumbing:
focus is asked for once per session, a failed connect does not ask at all — a
host-key prompt needs the keyboard on its own buttons — and locking stops the
forwarding. They cannot cover the focus call, because headless Avalonia has no
native window, so a headless test would focus correctly and confirm the wrong
belief; that is measured in the harness and written down in docs instead.
Dropping the forwarding fails two of them and dropping the detach fails one;
deleting the raise outright does not compile, since the event would be unused.

Reaching the connect path at all needed two new fakes. FakeRenderer attaches the
way the real page does — fetch the served page, read back the token and socket
URL the host substituted into it, then open the socket with both subprotocols —
rather than being handed the token, so the part of the handshake that has been
got wrong before stays under test. FakeSsh replaces a factory that would need a
reachable sshd, which DodoSSH.Client.Ssh.Tests already covers against a
container. The suite also never called workspace.Start(), so nothing served the
page and no renderer could have attached.

DllImport rather than the source-generated LibraryImport, which requires
AllowUnsafeBlocks for the whole project. The signature is blittable so there is
no marshalling stub to improve on, and turning unsafe code on across a client
that handles key material to gain nothing is a poor trade.

Correcting an earlier entry: docs/platform-flags.md described this as a
focus-plumbing gap and offered "click inside the terminal first" as the
workaround. Both true, and both stop short of the half that matters — focus
crosses into the WebView readily and never comes back on its own, which is the
same mechanism as the text boxes that mysteriously stopped accepting keystrokes
in the airspace entry above it, not a separate fault.
2026-07-29 14:31:09 +02:00
jaap-jan ea271d980a Give the realm's users their roles, and sign in as one in the E2E suite
Signing in failed at the token exchange with `400 Offline tokens not allowed
for the user or client`. A user declared in a realm import gets no role
mappings at all unless realmRoles lists them — not even the realm's own
default-roles composite, which Keycloak grants automatically to a user created
through the admin API or the registration form. alice and bob had none, and
offline_access lives inside that composite, which the desktop client requests.
Verified against the running Keycloak: alice's role-mappings were {} before and
resolve to default-roles-dodossh, offline_access, uma_authorization after.

The authorization request succeeds and the failure lands one step later, at the
code redemption, which makes it read like a client bug. It is not.

The E2E suite could not catch this because it created its own account through
the admin API — exercising a provisioning path no real user takes, and passing
while the account the README tells you to use could not sign in at all. It now
signs in as the realm's own alice, which is sound because the Keycloak and
PostgreSQL containers are per-run so the account is pristine, and this assembly
holds one test. Removing the roles again fails it with exactly the reported
message; that is what makes the coverage real rather than nominal.

Two traps recorded in docs/platform-flags.md, the second found by shipping it
for a moment: Keycloak's RealmRepresentation deserialises with
FAIL_ON_UNKNOWN_PROPERTIES enabled, so the "_comment" key I first used to
explain the roles inside the JSON did not get ignored — the import threw and
the container refused to start. Explanations go in the docs, not in the realm
file.
2026-07-29 14:11:16 +02:00
jaap-jan f80b3d4351 Harden the WebView collapse, and replace its evidence with a measurement
An adversarial review of 0500e43 did not refute the fix but closed the gap I
had left open and found three hazards around it. A standalone spike — a 60-line
Avalonia app with no DodoSSH code — reproduces the airspace bug on a 340,* grid,
and a second harness mirroring the data plane's handshake measures what I had
only reasoned about: with IsVisible=false set before the window is ever shown,
the adapter is created, the page is fetched and the WebSocket 101 is sent, with
frames arriving over the socket while hidden. A cold WebView2 profile behaves
the same. Revealing recomputes bounds in about 7 ms.

So the docs no longer cite "35 msedgewebview2 processes" as the confirmation
that the renderer attaches. A process count cannot show that a socket was
accepted — the same shape of mistake, one level down, as the one that entry was
already correcting. It now cites the handshake, quotes Avalonia's maintainer on
airspace being by design, and links the still-open upstream issue.

Three changes to the fix itself:

- terminal.js skips the fit below 40px in either axis. The vendored fit addon
  floors its proposal at 2 columns by 1 row rather than refusing, so a
  degenerate viewport reflows the *remote* pty through window-change and
  mangles wrapped scrollback unrecoverably. Reachable today by minimising, and
  by dragging a splitter to the edge once splits land — a guard where the sizes
  arrive, not a special case for one caller.
- FallbackValue=False on the binding. A compiled binding with no DataContext
  yields UnsetValue, IsVisible falls back to true, and the occlusion returns
  silently. Not reachable at runtime; it is what the previewer does.
- The comment now says why it must be IsVisible on this control: detaching
  destroys the native control and the whole WebView2 process tree, so
  conditional content would pay a cold start per unlock, and hoisting the
  binding to an ancestor is unverified because NativeWebView's own
  bounds-and-scaling re-push fires only for its own IsVisible.

Also recorded, not fixed: hiding does not suspend the page (visibilityState
stays "visible" and rAF keeps firing at ~115/s, which is *why* the handshake
completes while hidden); the conflict log can squeeze the terminal row toward
nothing; and nothing hands the terminal Win32 focus after Connect, so the first
keystrokes go to the shell's UI rather than the remote shell.
2026-07-29 13:42:23 +02:00
jaap-jan 7226e70b8a Record that a sub-path server URL is silently dropped
Found while sweeping for the stale default. The client uses the typed address
only as HttpClient.BaseAddress and every request path is root-absolute, so
https://example.test/dodossh reaches https://example.test/api/v1/... with the
prefix discarded and no error — which rules out hosting under a sub-path, the
usual arrangement behind a proxy fronting several services. The server
already publishes a canonical apiBaseUrl the client could normalise against
and ignores.

Recorded rather than fixed: it is a deployment-shape decision, not a bug in
the screen that prompted this.
2026-07-29 13:27:12 +02:00
jaap-jan 0500e43e02 Stop the terminal's WebView painting over the setup screens
The shell layered its setup and unlock screens over the terminal, which does
not work: NativeWebView attaches a real Win32 child HWND through
NativeControlHost, and a child window composites above everything its parent
paints regardless of visual-tree z-order. The cards rendered sliced at the
terminal column's left edge; at the window's default width every one of their
buttons fell inside the WebView's rectangle, so the flow could only be
completed by keyboard, and a click in that region handed Win32 focus to
WebView2 so the text boxes silently stopped accepting keystrokes.

The WebView is now collapsed while the vault is not unlocked. The comment
that previously forbade this — hiding it means never realising it — was
wrong: NativeControlHost creates the native attachment on attach to the
visual tree, never consulting layout or visibility, and NativeWebView replays
a Source assigned before its adapter exists. A collapsed WebView still starts
WebView2, loads the page and lets the renderer attach. Confirmed: 35
msedgewebview2 processes with the control collapsed. What the first
connection after unlocking actually depends on is the existing await on
WaitForRendererAsync, since the data plane drops frames when no renderer is
attached.

Also fixes the second visible defect: the default server URL was
https://localhost:7217, the API's *second* launch profile, while the README,
its appsettings and a plain `dotnet run` all use http://localhost:5233 — so
nothing was listening, and an HTTPS client against a plaintext port reports
"The SSL connection could not be established", which reads as a certificate
problem. The default now matches, a missing scheme is rejected by name
instead of parsing as scheme "localhost", and that specific TLS failure now
suggests http://. Both new tests fail when the fixes are reverted.

Corrections to claims I made earlier and should not have:

- docs/platform-flags.md asserted the opposite of the mechanism above and
  cited an established msedgewebview2 connection as verification. That
  observation was taken while the overlay was showing but, because of this
  very bug, the WebView was uncovered and in plain view — so it confirmed
  only that a visible WebView is realised. A process-level check cannot
  verify a rendering claim. The entry was also filed under "Local cache".
- ITerminalHost was documented as the live seam the app plugs into, with a
  stub standing in for headless tests. It has no implementation anywhere and
  no test uses it; the view navigates the control directly. It also counted
  Avalonia.Controls.WebView and NativeWebView as two interchangeable
  backends when they are one component, with the Linux backend backwards.
- The README claimed the shell's whole path was covered by tests. Its state
  machine is; its layout is covered by nothing, and a headless test could
  not have caught this — headless has no native window, so it would have
  rendered correctly and confirmed the wrong belief.

Verified by screenshotting the running app: the card renders complete and
centred at the default size, with the button clickable.
2026-07-29 13:26:30 +02:00
jaap-jan b9e7c258ae Point the design-time factory at the stack the repo ships
`dotnet ef database update --project src/DodoSSH.Infrastructure` — the
command the README documents — failed on a clean machine. The design-time
default named `dodossh_design` as user `postgres` with no password, which is
a database this repository never creates, while the development compose
stack creates `dodossh`/`dodossh`. The failure arrives as a SCRAM
authentication error, so it reads like a broken container rather than a
stale default.

The default is now the compose stack, since that is the only local database
the repo defines. DODOSSH_DESIGN_CONNECTION still overrides it, and a real
deployment migrates through that or the migrator job.

Also documents running the thing end to end, which the README never covered:
the four commands in order, that migrations are a separate step because the
API deliberately fails readiness rather than migrating, and the three M1 gaps
visible in the first five minutes — so they are expected rather than
diagnosed.
2026-07-29 12:17:59 +02:00
jaap-jan 34304b989b Make the end-to-end suite self-contained with Testcontainers
It needed a hand-started stack and an opt-in flag, so it ran on one machine
and never in CI. It now brings up PostgreSQL, Keycloak and an OpenSSH server
itself, applies the committed migrations and starts the API as a child
process, which makes it part of the ordinary test run at ~25s.

The API runs as a process rather than through WebApplicationFactory. The
client builds its own HttpClient for a URL the user typed, so there is no
seam to hand a test handler through without inventing one that exists only
for tests — and a test host would replace the entry point, Kestrel and the
content root, so it would never prove that Program.cs composes or that the
committed appsettings is found and layered in the documented order. Running
out of the API's own output directory is what makes its configuration real.

The suite still consumes what ships: the realm file from deploy/keycloak,
the EF migrations, the API's own appsettings. Only Oidc:Authority is
overridden, because the container's port is assigned at start. Falsified by
reintroducing the wildcard-port redirect URI the realm once had — Keycloak
rejects the authorization request and the suite fails at sign-in, which is
what proves the committed file is the one imported. Skipping the migration
step likewise fails, and the failure names the pending migration.

A fresh Keycloak per run also sidesteps the --import-realm trap: editing the
realm file and rerunning now always tests the edit.

DodoDbContextFactory gains a Create(connectionString) so the fixture and
dotnet ef place the migrations history table in exactly one place. If they
disagreed the API would report every migration pending, which is how the
readiness gate catches it.
2026-07-29 12:09:15 +02:00
jaap-jan 1d262b7ccc Run M1's end-to-end slice, and fix the two bugs it found
The whole vertical slice now runs against a real Keycloak, a real API, a
real PostgreSQL and a real sshd: sign in through the browser flow, enroll
with the identity-provider key binding, unlock, create a host, sync it,
read it back on a second machine, unlock again with no network, accept an
unseen host key, and open an interactive shell. Opt-in, because it needs
the development stack; skipped with a message naming the commands.

It found two bugs on its first run, and both are the same class: two
sides of a stub agreeing with each other about something the
specification never said.

**The API never applied DodoSshJsonContext to its HTTP JSON options.**
Minimal APIs therefore used the framework's web defaults, which write an
enum as a number. Every request DTO carrying one failed to bind against a
client writing the specified string form — which is the entire sync
surface, unreachable from the real client, with a 400 naming only the
parameter. The documented guarantee that request bodies reject unmapped
members was likewise not in effect anywhere.

Nothing caught it because the API tests posted with PostAsJsonAsync's
defaults, so they and the server had independently settled on integers.
Those tests now serialise through the contract, which is the deeper fix:
removing the new configuration fails 13 of them. Copying settings into
options a host owns is itself the hazard the context warns about, so
ApplyTo lives beside the settings it mirrors and ApplyToTests pins the
transformation, including that inserting the resolver leaves the caller's
own in place.

**The realm registered a loopback redirect URI Keycloak rejects.**
`http://127.0.0.1:*/callback` looks more explicit than the RFC 8252 form
and is broken: Keycloak's wildcards are trailing-only, so the `*` parses
as a literal port and every authorization request came back "Invalid
parameter: redirect_uri". Providers ignore the port for loopback hosts,
which is the whole mechanism, so the correct registration is
`http://127.0.0.1/callback` — path pinned, port free. The value the
server advertises through the discovery document said the same wrong
thing and now says the right one.

Two smaller things, both documented in docs/platform-flags.md:

- --import-realm skips a realm that already exists, so editing the realm
  file and restarting Keycloak changes nothing and serves stale
  configuration. The container has to be recreated. The compose comment
  claimed the opposite.
- Keycloak marks its session cookies Secure even over plain HTTP, because
  SameSite=None requires it. A spec-conformant client drops them and the
  login POST answers 400 with no message; browsers complete the flow only
  because they exempt loopback. Harmless for the product, fatal for
  automation, so ScriptedBrowser carries the cookies by hand and says why.

Also: the server enforces a 64 MiB floor on the passphrase KDF, so this
suite cannot use the 8 MiB profile the other client suites take for
speed. Those only get away with it because their in-memory servers have
no policy — worth knowing rather than rediscovering.

638 tests. The solution-wide run stays green with the stack down: exit
code 8 means "no tests ran", which the platform reports as failure, so
the opt-in project ignores exactly that code.
2026-07-29 11:37:49 +02:00
jaap-jan 49f617b450 Wire the Avalonia shell to the vault
The host list now comes from the vault instead of from a form. A fresh
machine takes a server URL, signs in through the browser, enrolls, and
from then on opens with the passphrase alone.

DodoSSH.Client.Session is the composition layer: where a profile lives,
how it unlocks, and how a machine gets one. ClientPaths picks a
non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%,
because a SQLite cache that roams between two machines is a corrupt one,
and each machine's outbox is its own. SessionOpener needs no transport at
all and could not reach one if it wanted to; that is the offline unlock,
asserted rather than asserted about. A wrong passphrase, a stale KDF and a
grant revoked by a rekey are three different answers, because the remedies
are three different things and telling someone to retype a passphrase that
was never the problem is worse than saying nothing.

The shell's states are the onboarding story. The recovery code gets its
own state that cannot be clicked past: it exists for one moment, losing it
with the passphrase loses the vault, and there is no server-side reset by
design. It is dropped from memory on confirmation rather than merely
hidden.

Sign-in is a delegate over IVaultServer, so the whole state machine runs
in a test against an in-memory server — no browser, no identity provider,
no toolkit. The view models are plain observable objects, which is what
makes that possible. What it does not cover is whether the XAML binds to
the right names; that needs a rendered tree and Avalonia.Headless, and is
its own piece of work.

Three things found by doing it rather than by reading it:

- Pooled SQLite connections keep the database file open after the last
  context is disposed. On Windows that means locked, so the application
  could never replace its own cache — and a test could not clean up after
  itself, which is how it surfaced. Dispose now clears the pool.
- EF's SQLite provider puts the database in WAL mode, so the cache is
  three files. A comment in ClientCacheFactory claimed the opposite;
  reading PRAGMA journal_mode off a real launch settled it. WAL is the
  right mode here — a sync pass writes while the interface reads — so the
  comment was wrong on the merits as well as on the fact.
- Enrolling a device key with nowhere to keep the private half would put a
  wrap on the server nobody can open and make the device list claim this
  machine can unlock without a passphrase. Device binding is now optional
  and the shell declines it until the OS keystore is wired.

Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db
and migrated it on first launch, and msedgewebview2 held an established
connection to the data plane while the unlock overlay covered it — which
is the point of covering the WebView rather than collapsing it, since a
NativeWebView that is never laid out is never realised.

630 tests, up from 593. The recovery-code gate and the offline unlock were
each verified by breaking them and watching the right test fail.

Still to do for M1's actual definition of done: the manual run against the
real API and a real Keycloak. Credentials are not a synced entity type
yet, so a connection still asks for a password, and the interface says so
rather than implying otherwise.
2026-07-29 11:02:19 +02:00
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
2026-07-29 10:27:37 +02:00
jaap-jan a878c2b6bb Add the server client and client-side enrollment
A typed client over DodoSSH.Contracts, and the orchestration that turns a
passphrase into an enrolled identity: generate keys, have the identity
provider sign over them, wrap the bundle three ways, create the personal
vault, publish.

Ordering here is forced, not chosen. The secret bundle's AAD binds to the
server-assigned user id, so /me has to be read before anything can be
wrapped -- which is exactly why /me provisions the account and returns its id
even while reporting that enrollment is required. That constraint was
designed into the server earlier; this is the first code that depends on it.

The grant tuple now has a real canonical encoding (crypto.md 7.3) rather
than the placeholder signature I would otherwise have had to invent and then
keep. §7 named the tuple without specifying how to encode it; this fills that
in with the same conventions as 7.1, and the self-grant at enrollment is
already in its final format. The signature covers SHA-256(wrappedKey) rather
than the key, so a verifier can check attribution without holding the vault
key at all.

The most valuable tests are the negative ones about the request body: the
server is meant to be unable to read what it stores, and a refactor that put
a passphrase or a private key into the enrollment request would be invisible
to every other test in the repository. So one asserts the body contains
neither the passphrase, the recovery code, nor any private key in base64 or
hex. Another opens the same bundle three ways -- passphrase, recovery code and
device key -- which is what makes a passphrase change a one-row update.

ClientEnrollment depends on IKeyBindingAuthorizer rather than the whole
OidcClient. It needs exactly one capability, and depending on the full client
would drag discovery and token exchange into every test of key binding.

Two things fixed while building it. The recovery code buffer was sized one
separator short, so every enrollment threw IndexOutOfRange -- caught
immediately because nine of ten tests failed identically. And the crypto
enum collided with Domain.GrantKind in the server, so it is GrantPurpose
there; the numeric values still have to match, which the doc and a test both
say.

448 tests pass, zero warnings on a clean rebuild, format clean.
2026-07-28 22:42:56 +02:00
jaap-jan 5fccd53824 Add the Avalonia app and the xterm renderer, and fix two real bugs
The terminal works end to end. A new integration test drives a real sshd in
a container through a real PTY, the real pump, the real loopback WebSocket
with its token and origin checks, and a ClientWebSocket standing in for the
page: the login banner arrives, typed input round-trips, and `stty size`
reports the 100x30 the session asked for. The only untested link left is
xterm drawing bytes it was handed.

The WebView is de-risked on Windows, which was the plan's largest risk. Not
by assertion: with the app running there is an established TCP connection
from msedgewebview2 to the data plane port, so WebView2 launched, navigated
to the loopback page, executed terminal.js, and completed the WebSocket
handshake against the real token and origin checks. Linux remains unproven
and the package's own release notes now corroborate the concern -- Linux uses
a WPE backend, and it ships a NativeWebDialog described as useful where
embedded WebViews may be unavailable.

Two bugs found by building it, both of which would have shipped:

- ShellStream.Write buffers and needs an explicit Flush. Without one a
  keystroke is accepted, reported as written, and never reaches the remote:
  the terminal displays output perfectly and simply stops responding to
  input. SSH.NET's own WriteLine flushes, which is why the earlier spike
  never hit it. Found by isolating the pump against real SSH and reading
  BytesRead=51 -- banner and prompt through, nothing after.
- The Windows app manifest needs a supportedOS list, or Avalonia's native
  control host fails outright and the terminal never starts.

Also fixed a genuinely flaky test I happened to catch: SyncCursorTests
tampered with the *last* base64url character, whose low bits the decoder
ignores when the input length is not a multiple of three -- so a tampered
cursor sometimes decoded to identical bytes and verified. It failed roughly
one run in thirty, depending on a random key. Now tampers the penultimate
character, which is fully significant at every length; 40 consecutive runs
are clean.

xterm 6.0.0 plus the fit and webgl addons are vendored as UMD bundles rather
than built with npm, so a clean clone needs only the .NET SDK. Provenance
and licences are recorded next to them, along with the UMD global names
terminal.js depends on -- a bundle that switched to ES modules would load
without error and leave Terminal undefined.

The renderer acknowledges output from term.write's completion callback, not
on receipt. Acknowledging early would return flow-control credit for bytes
the screen has not caught up with, which is the one thing the credit window
exists to measure.

TerminalWorkspace moved into DodoSSH.Client.Terminal: it has no Avalonia
dependency, and having it there is what let the end-to-end test exist at all.

404 tests pass, zero warnings on a clean rebuild, format clean.
2026-07-28 22:30:42 +02:00