Commit Graph
23 Commits
Author SHA1 Message Date
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 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 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-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 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 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 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 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 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
jaap-jan 94f66be5e8 Add the OIDC client: PKCE loopback sign-in and the key binding flow
Authorization Code with PKCE on a loopback redirect, per RFC 6749, RFC 7636
and RFC 8252. Zero package references: the flow is fully specified, and the
one thing a library would own for us -- nonce generation and validation -- is
exactly what the key binding needs to control. Duende's OidcClient generates
and validates its own nonce as an internal detail, and the binding requires
the nonce be a specific value: the hash of the key statement being enrolled.
Fighting that is worse than owning the flow.

AuthorizeKeyBindingAsync is the client half of the primary trust anchor. It
runs a second authorization with nonce set to the statement hash and
prompt=login, so the ID token that returns is the provider's signature over
exactly those public keys, attesting to a user present now rather than to a
session opened at some unknown earlier time. It requests only openid -- a
second refresh token would be one more long-lived credential for no benefit
-- and rejects a token whose nonce is not the one it asked for, because
enrolling that would store evidence verifying against keys we are not
publishing.

The nonce is read without validating the ID token's signature. Sanctioned by
OIDC Core 3.1.3.7: for a token received by direct communication with the
token endpoint, TLS server authentication may stand in for signature
checking. That reasoning does not extend to another user's binding, which
arrives via the DodoSSH server and must be verified against JWKS fetched
directly -- the directory work in M3.

Raw TcpListener rather than HttpListener for the redirect: an ephemeral port
can be bound and read atomically instead of picking one and hoping it is
still free, there is no HTTP.SYS URL-ACL question on Windows, and the whole
surface is one request line. It answers 404 on other paths and keeps
waiting, because a browser asks for /favicon.ico first and treating that as
the callback would abort every sign-in. 127.0.0.1 rather than localhost: RFC
8252 permits either, but the name resolves through the hosts file.

20 tests, driving the real listener over TCP with a fake browser that
actually fetches the redirect -- injecting a fabricated callback would skip
the parsing, path filtering and response writing that can break. Mostly
negative, because the loopback port is reachable by every local process: a
response with the wrong state is rejected *and* never reaches the token
endpoint, metadata declaring an issuer other than its own authority is
rejected (RFC 8414 3.3, without which a mix-up attack works), a provider
offering only 'plain' is fatal rather than a silent downgrade, and the
verifier sent is checked against the challenge advertised so PKCE is not
theatre that only fails in production.

Two bugs caught by writing the tests: the authorize URL builder dropped
client_id entirely after a refactor, and CancellationTokenSource.CancelAfter
has no TimeProvider overload -- so the browser timeout is now constructed
with the clock and a test can advance it instead of waiting five minutes.
2026-07-28 21:13:35 +02:00
jaap-jan 885fb17bdc Clear the SSH gate: window-change reaches the remote, and licence as MIT
Licence is MIT, set solution-wide rather than only on the packable project:
DodoSSH.Contracts is published so clients can build against it, and a
package with no licence expression is one a corporate policy scanner
rejects outright.

The SSH.NET spike is the M1 client gate and it passes. SSH.NET 2025.1.0
exposes ShellStream.ChangeWindowSize, but a method existing is not the
remote observing it, so the tests read `stty size` back from a real sshd
after resizing rather than asserting the call did not throw. Repeated
resizes each take effect too, which matters because dragging a window edge
produces a stream of them. The IChannelSession fallback is not needed.

Also verified against a real sshd: password and public-key auth, that the
host key arrives as a raw blob we can fingerprint ourselves rather than
reading SSH.NET's MD5 property, and that refusing the key via CanTrust
actually aborts the connection -- without which the TOFU dialog would be
decoration.

Kept as a permanent suite, not deleted after the spike. An upgrade that
silently stopped sending the request would present as wrapped output only
after a resize, which is easy to misattribute to the terminal emulator.

Two bugs in the test itself, both worth naming because either would have
been read as "resize does not work":

- A PTY emits CRLF, and the anchored regex rejected the CR. The output
  visibly contained `24 80` while the match failed.
- Each read can begin with output still buffered from the previous command,
  including its size line. Taking the first match would have reported the
  pre-resize size.

platform-flags.md now records window-change as resolved rather than
unverified -- a stale flag is worse than none -- plus the three real SSH.NET
limits found on the way: ShellStream does not override ReadAsync so every
idle session parks a pool thread, one connection cannot serve both
SshClient and SftpClient, and agent forwarding needs an upstream change.
2026-07-28 16:52:09 +02:00
jaap-jan b7325b78ca Record the platform flags that were only in conversation
ci / build and test (ubuntu) (push) Has been cancelled
ci / build (windows) (push) Has been cancelled
Development and testing are Windows-only, so anything unverified elsewhere
needs to be written down or it gets assumed to work. Several of these have
already cost time once: PostgreSQL 18 moving its data directory silently
gives a carried-over compose file an empty volume, and a loopback-bound
Tomcat beat Docker's 0.0.0.0 publish for `localhost`, making every Keycloak
realm 404 while the container looked healthy.

The largest entry is the Avalonia WebView on Linux, which remains the
biggest risk in the plan and is why the terminal sits behind ITerminalHost.

Also records two things this milestone deliberately left undone -- no rate
limiting on the enrollment and sync write paths until M2, and /me not
touching last_seen_at_utc -- so neither reads later as an oversight.
2026-07-28 16:08:04 +02:00