Public Access
365954c5412e151f77668b14c97af832548b1f54
233
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
365954c541 | Desktop: probe for the TPM once, off the UI thread, instead of on every unlock | ||
|
|
3ccafdd53b | Desktop: drop the VAULT columns that had nothing in them, and the snippet buttons with nothing to act on | ||
|
|
22b4d82ba8 | Desktop: stop the status bar's session timer when the shell ends | ||
|
|
619f49a35a | Desktop: trim a long session tab label instead of letting it push the + off the strip | ||
|
|
a6d3924e0d | Desktop: call device unlock what this platform calls it, not Windows Hello everywhere | ||
|
|
079914bfb4 | Desktop: let the arrows choose the host the drawer acts on, and Enter connect to it | ||
|
|
6c09bdecb6 | Desktop: give the host-key, delete and sign-out questions the keyboard on their harmless answer, and let Escape say no | ||
|
|
372a7cbdc7 | Desktop: put the keyboard in the closed vault's card on launch, and let Enter sign in and enrol | ||
|
|
a4b81da597 |
Desktop: show the right vault's people, leave settings on lock, say when an import fell short, and stop host names being cut to four letters
- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
panel kept showing the previous vault's people under the new name. The read now starts regardless;
the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
Keychain names trim inside their column instead of printing over TYPE, and the Security page's
caption wraps inside its card.
|
||
|
|
0e8fe4fc80 |
Give each profile its own device key, and make the Linux PIN window able to open at all
- The Windows CNG key and the macOS keychain tag were one name for every profile, so signing out of the nightly deleted the key the release build's device unlock was sealed to. DeviceKeyNames keeps the existing name for the default profile and gives every other profile its own; sign-out deletes only its own key, and a profile sealed before this keeps reading the shared one. - DevicePinWindow named two palette resources that do not exist and loaded itself with AvaloniaXamlLoader.Load, which never assigns the named fields, so it threw on construction and no PIN prompt was ever shown. A layout test now builds it against the real palette. |
||
|
|
2ff9dff560 |
Terminal: deliver large pastes whole, keep keystrokes in order, and keep one session's failure off the shared socket
- A message larger than the 64 KiB receive buffer was skipped and its tail dispatched as a frame of its own. Messages are now reassembled up to 16 MiB; one beyond that is dropped whole. - Input frames are handed to the pump without waiting, and ShellStream takes no lock around its write buffer, so a snippet's text and its carriage return could interleave or lose bytes. Writes are serialised per pump in arrival order. - A resize reaching a shell SSH.NET had already disposed threw out of the receive loop and closed the socket every tab shares. Each frame's failure now stays with its session. |
||
|
|
6846f67c20 |
Server: send each item at the revision its ciphertext is sealed at, keep team archiving with the owner, refuse suspended accounts, and stop enrolment failing after an hour
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the later entry carries the same payload under the right number, and a fresh device no longer downloads an item once per edit it ever had. - Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks. - UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers 403 account-not-active for a suspended or deprovisioned account. - The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The entity is reloaded after the update. |
||
|
|
f332d9a56f |
Stop a finished push deleting the edit made while it was in flight, and pull a newly synced type from the start
- The outbox coalesces a new save into the row a push already read, under a new operation id, but Complete, Revise and Park found the row by sequence alone. The acknowledgement of the earlier state deleted the row holding the later one and the edit was gone without a word. They now take the PendingOperation and act only while the row still holds it; a later edit is rebased onto the version just accepted, so it pushes cleanly instead of conflicting with its own earlier save. - A pull filtered by type moves the cursor past changes of types it did not ask for, so a build that added a type (SpelunkKey, ObjectStore) never received the items of that type made before it. sync_state.pulled_types records the list a cursor was read under, and a different list starts the pull over once. Migration AddSyncStatePulledTypes. |
||
|
|
03c8e8589a |
Keep downloads inside the folder they were sent to, never run a file opened for editing, and let a bucket upload be retried
- A remote name went straight into Path.Combine. A POSIX name may hold backslashes, a colon or a drive letter, and an S3 key may hold "../", so a name chosen by whoever could write to the host decided where on this machine a download landed. LocalDirectory.ChildPath cleans the name and requires the resolved parent to be the target folder; every download path and EDIT use it, and a name that cannot be a file there is left on the host and reported. - EDIT handed the copy to the shell's default action, which for .bat, .hta, .js and friends is to run it. LocalEditor opens such a file with its own "edit" verb or Notepad on Windows and `open -t` on macOS, and refuses a .desktop launcher on Linux. - A failed bucket upload leaves its part object, and a retry asked for an offset no bucket accepts, so the row could never finish. IRemoteFileStore.CanWriteFromAnOffset is false for buckets and the queue starts those over. - The SFTP host picker re-found its selection from the empty moment of every vault reload and landed on the first host once a minute; the chosen host is now remembered, and a typed password is only sent to the host it was typed for. |
||
|
|
4c7f47c026 |
Refuse a pinned host that answers with a key of another type, rather than asking whether to trust it
Pins are stored per algorithm, so an endpoint pinned for ssh-ed25519 that was answered with only an ECDSA or RSA key found no pin and raised SshHostKeyUnknownException: the first-use question, where a pin exists to produce a hard refusal. Anybody in the middle needed nothing more than a key of a different type. The connection factory now reads every pin for the endpoint before the handshake, offers the pinned algorithms first so a server that still holds its pinned key negotiates it, and in the gate accepts a key under an unpinned algorithm only when its fingerprint matches one of the endpoint's pins (one RSA key signs as rsa-sha2-256 and rsa-sha2-512). Anything else is a mismatch. IKnownHostStore gains FindForEndpointAsync for this. |
||
|
|
b08f2c58e3 |
Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting. A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed. A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for. The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log. |
||
|
|
38ab9b431b |
Sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.
A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
|
||
|
|
689ba6c1bf |
Merge origin/main into claude/spelunk-tunnel
Port forwarding took host schema version 8 first, so the Spelunk route moves to 9 and is written after the forwards. A host may carry both. |
||
|
|
6e39807cc0 |
Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.
Client, in Client.Session:
- SpelunkTunnel implements Spelunk's contract: POST
{api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
and {"port":N}, then a WebSocket to the API's own origin at the path
Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
could not reach the port, or is too old: "Unknown stream kind") and 4503
(agent disconnected). Only a path is taken from the ticket response, so it
cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
so the connect path has one shape for both.
Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.
The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.
Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.
UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.
dodossh://spelunk/connect?api=&server=&name=&host= links:
- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
running passes it down the doorbell as "dodossh.open-link <link>", which
the running instance parses again, bounded, and otherwise ignores whole.
The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
otherwise the editor opens on a new, unsaved Spelunk host, with the key
pre-chosen only when another host already sends it to that Spelunk. A link
that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
hooks and removes it on uninstall only if it still points at this
executable; the macOS plist template declares CFBundleURLTypes and the app
takes the URL from Avalonia's activation event; the Linux desktop entry
gets %u and MimeType=x-scheme-handler/dodossh.
|
||
|
|
d7d002c972 |
Merge pull request 'Start port forwards on the phone, listed in a sheet off its terminal' (#20) from claude/dazzling-moser-641acd into main
Reviewed-on: #20 |
||
|
|
c3dfa42108 |
Start port forwards on the phone, listed in a sheet off its terminal
The Android head skipped a host's forwards because it had nowhere to show what was listening. Its terminal bar now draws a ⇄ for a tab that has forwards, carrying the listening count in green and turning the danger colour when one failed, and it raises a sheet over the same rows the desktop sidebar lists: notation, route, state, failure reason and a tap to switch each off or on. MainWindowViewModel.StartForwards no longer returns early on Android. The sheet is shell state like the connect sheet: it collapses the renderer while up, is lowered by leaving the terminal, and the back gesture closes it first. Forwards ride the shell's keep-alive rather than holding the foreground service themselves, since a forward cannot outlive its shell. They only change the notification, which now counts ports forwarded beside shells connected, fed by a new ForwardsChanged event and ListeningForwardCount. The phone still has no editor for forwards; the More screen card, README and design-import-gaps now say they run here and are edited on the desktop. |
||
|
|
a6a21decbd | Report a host's pinned paths among the fields an edit changed | ||
|
|
f05deb4fbc |
Add port forwarding: local, remote and dynamic, per terminal session
A host can now carry port forwards (-L, -R, -D), saved inside its encrypted payload as RelayTicketRequest.PortForwardId already assumed, so they sync and merge per forward like tags and pinned paths. The host schema moves to version 8 only for hosts that carry forwards. ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports. Every listener binds loopback and there is no bind-address field: a forward is something any member of a shared keychain can edit. A remote forward's target is resolved here, IPv4 first, rather than by SSH.NET's constructor, which takes the first DNS answer and can send ::1. Forwards start with every terminal opened to the host and stop with its connection; a reconnect starts them again. The session sidebar lists them with state, failure reason and an on/off switch, the status bar counts the listening ones, and the host editor and detail pane gain a PORT FORWARDS section. A forward that cannot start fails alone. The phone does not start forwards yet: it has no surface showing what is listening, and its More screen now says so. No byte counts, since SSH.NET keeps none. |
||
|
|
c8848ec1e3 |
Send the event socket's close code instead of aborting the socket
The events endpoint cancelled its pump token and only then wrote the close frame. The token was also the one passed to socket.ReceiveAsync and SendAsync, and cancelling a pending operation on the ManagedWebSocket that Kestrel hands over aborts it. CloseAsync then saw an Aborted socket and returned, so in production a client whose token expired got a TCP reset rather than 4401 "reconnect with a fresh token", and a shutdown sent no 1001 either. The tests missed it because TestServer's in-memory socket survives that cancellation: the same scenario through TestServer closes with 4401. PumpAsync now does what the relay's pump does. No WebSocket operation is cancelled; the token stops only the outbound queue, the clock and the access refresh. The sender is let finish its last write, the close goes out, and the receiver ends on the client's answering close. Each wait is bounded by the close timeout, and the socket is aborted only when a peer stops answering. ApiFixture gains ConnectEventsOverTcpAsync, which runs a second host on real Kestrel over loopback, sharing the container and identity stub. The new AnExpiringToken_ClosesTheSocketWith4401 fails with a reset before this change and passes after it. |
||
|
|
b20f7cae04 |
Let a host marked for the relay actually be reached through it
The relay checkbox said it was not wired up, and the plan said the server half was built and shipped. It was not: there was ADR 0004, the contract records, RelayOptions and a CHECK constraint, and RelayOptions said in its own remarks that there was no endpoint and no dialler. So both halves are built here, as step 3 of docs/reaching-a-host-you-cannot-dial.md. Server, in Features/Relay: - POST /api/v1/relay/tickets makes every decision: the host, Connect on its vault, the relay switched on, and the stored name resolved once and judged by RelayAddressPolicy. Loopback, link-local, 0.0.0.0/8, multicast and the metadata endpoints are refused with no configuration escape, with IPv4-mapped and NAT64 spellings unwrapped first; then the operator's denials, allow-list and private-network switch. What passes goes into a Data Protection ticket, and nothing resolves the name again. Every target refusal is the same 403, so the endpoint cannot be used to map the operator's DNS; the real reason is logged. - GET /api/v1/relay/connect takes the ticket as a second subprotocol, checks its expiry against TimeProvider and spends it with a unique insert into relay_ticket_use before accepting the upgrade. After the upgrade a refusal is a close code: 4502 for a host the server could not reach, 4429 for a limit, 4408 idle, 4409 lifetime, 1001 after a drain. - relay_session is the audit row, written from short-lived scopes. Client: - RelayBridge is a loopback SOCKS5 listener that accepts once and pipes to the relay socket. It rides on the new SshLoopbackProxy.Lifetime, so the connection disposes it on every way a session ends and a failed connect disposes it before throwing. - ServerRelay, exposed as IVaultServer.Relay from /api/v1/meta, asks for the ticket, opens the socket, starts the bridge and turns every refusal into a sentence. - VaultViewModel and TransfersViewModel reach a relayed host through it, never fall back to dialling it directly, and replace SSH.NET's transport error with the relay's own reason when it gave one. Neither side ever cancels a WebSocket operation. Cancelling one aborts the socket, and an aborted socket sends no close frame, which is the only way the client learns the server could not reach the host. AnIdleSession_IsClosedWithTheIdleCode asserts that the code arrives. Three departures from ADR 0004, recorded in it: awaited copy loops rather than Pipelines (same backpressure, two 32 KiB buffers), no ?ticket= fallback, and no device id in the ticket. Tests: API 245, shell 582 (5 skipped), layout 170, SSH 110 including a real login through the bridge, API client 30, contracts 25. Not run: the Android build (its change is copy only) and the Alpine layout oracle. |
||
|
|
8e61fc72b4 |
Stop an attach's own replay landing in the middle of a test's frames
TheClipboard_ReachesThePaneThatAskedForIt failed in CI on an opcode of 2 where it wanted 8: a second SessionOpened frame where the clipboard's should have been. Only one thing sends a session a second SessionOpened, and that is ReplayAfterAttachAsync, the replay a reattaching page gets. It arrives there because the replay is fired and forgotten from the socket-accept thread immediately after the attach is signalled, and the test helper returned on that signal. Deschedule the accept thread for a timeslice between the two -- which a loaded CI box does -- and the test opens its session first, so the replay finds that session live and sends it a replay-flagged SessionOpened after its real one. Nothing is wrong with the workspace. The page treats a replay onto a pane it still has as idempotent, and ReplayAfterAttachAsync's own remarks already say racing a fresh page's first frames is harmless. What was wrong is a helper that waited for the attach and then let its callers count frames as though the attach were finished, so ConnectRendererAsync now waits for RendererReattached as well -- raised as the last thing the replay does. The remark there argued a replay could not be sent early; it missed that it can be sent late, and now says so. The same late replay could reset the credit window out from under ANewRenderer_ReplaysTheLiveSessionAndResetsItsCredits, which shares the helper, so that latent flake goes with it. Verified by delaying the replay's snapshot by a millisecond: the old test fails under that, and the fixed class passes it eight runs running. |
||
|
|
2d95366552 |
Stop a reconnect dialling a different machine of the same name
A tab remembered what its host was called and nothing else, and the reconnect under a stopped tab looked the host up by that name: HostFor took the first row in the keychain whose label matched. Nothing in this application stops two hosts being called the same thing — a team's vault beside a personal one makes it ordinary, and "prod-db" is what the database server gets called in both — so the button dialled whichever namesake sat earliest in the list. A dropped session on one machine came back as a live shell on another, with the tab, the address and the status bar all still naming the first. There was nothing on screen to read the substitution off. The name match was not an oversight; it was an argument, written into the remark on HostFor and repeated on RefreshConnectedHosts: a tab outlives the vault that opened it, so it "cannot hold an entity id that would still mean anything after a lock". That is not so. EntityId is the item's own sync identity rather than a reference to a row object, so a vault that is locked and opened again rebuilds every row around the same ids. The one handle that was rejected as too fragile to survive a lock is the handle that survives it. So the id now travels with the connection. ConnectionAttemptEventArgs and TerminalSessionEventArgs carry it, taken from ConnectionTarget.HostId, which the connect path has had all along for the log; TerminalTabViewModel keeps it; and HostFor matches on it. A host renamed since the tab opened is found too, which a name match by definition cannot. Null is not missing data — it is the whole of what makes a typed-in address different, and it is why such a tab is refused a reconnect rather than offered one that would dial a stranger sharing its name. Three other lookups had grown their own copy of the same match and so had the same fault, where it is read rather than dialled: the green connected dot, which lit on every machine answering to the name and claimed a shell on hosts nobody had connected to; the pinned paths QUICK ACCESS offers, which come from whichever row that same match found; and the sidebar's "Pin folder" and the SFTP tab row, which would have edited and browsed the wrong host. All four now go through HostFor, so "which machine is this tab" has one answer. Both new tests assert on the address actually dialled rather than on anything the tab says about itself. The two rows agree on every string a tab carries, which is exactly why this went unnoticed: a test written against the label would have passed on the day the wrong machine was being opened. |
||
|
|
9ead42171c |
Stop a flooding remote growing the process, and let an ended session go
The terminal's memory was bounded by an argument that is not true of the SSH library underneath it, and the whole of CreditWindow's remark was that argument: the pump stops reading when the renderer falls behind, SSH's own receive window closes, the remote sshd blocks on write, and the backpressure reaches the process producing the output. The middle step does not happen. Renci.SshNet's Channels.Channel.OnData calls its window adjustment before it raises DataReceived and re-opens the channel window as soon as it drops below one packet, and ShellStream's own handler copies the bytes into a System.Net.ArrayBuffer without blocking and without a ceiling — a buffer that doubles on demand and is not given back until the stream is disposed. So a reader that stopped reading slowed the remote down by nothing at all. It only chose whose buffer the flood landed in, and it chose the one nothing bounds. Measured against the container this repository already runs sshd in: a reader that reads nothing while `yes` runs leaves ShellStream holding 8.9 MB after one second, 26.8 MB after three and 44.3 MB after five, its capacity stepping 16 → 32 → 64 MB. That is the report of "a massive memory leak on Windows"; it is not a Windows fault, it is simply where long sessions with heavy output live. The fix is to read continuously, which is what keeps SSH.NET's buffer empty, and to put the bound where it can actually be enforced. OutputBacklog is that bound: a byte-capped queue of unsent output, 4 MiB by default, which drops its oldest bytes when full and counts what it discarded. Oldest rather than newest, because the tail of a flood is the prompt somebody is waiting for. The two bounds are now separate and each is enforced where it means something. The backlog bounds what is waiting to be sent. The credit window goes on bounding what has been sent and not yet drawn — the queue inside the WebView that nothing on this side can measure — and is reserved against in the flush loop rather than the read loop. Reading is part of neither, because reading was never what the remote was waiting on. Dropping is announced rather than silent. A terminal quietly showing a different transcript from the one the remote sent is worse than one that says it could not keep up, because the second can be believed. The line goes out under its own opcode, TerminalServerOpcode.OutputDropped, rather than spliced into the output stream: what this application says about the transcript is not part of the transcript, and keeping them apart is also what makes the arithmetic hold — every byte read is either in an Output payload or counted in BytesDropped. It opens with an SGR reset, because the cut lands at an arbitrary offset and whatever escape sequence the remote was in the middle of has been left half-said. Frames are now capped at 64 KiB and taken straight into the frame buffer. The cap is the large-object-heap threshold rather than a guess — a quarter-megabyte array allocated sixty times a second is collected only by a generation 2 pass — and the direct take removes an allocation and a copy of every byte the terminal shows. The second half is the one that made the first half's cost persist. Nothing gave a session back when the remote hung up: TerminalWorkspace kept the entry, and with it an undisposed ISshConnection — an authenticated socket, its cipher state, and a keep-alive putting a packet on the wire every thirty seconds — plus the pump and SSH.NET's read buffer at whatever size the session's busiest moment had grown it to, for as long as the tab stayed open. A tab somebody typed `exit` into an hour ago was indistinguishable from a live one in everything except what it drew. ReleaseAsync disposes both, from AnnounceEndedAsync and after the announcement: the tab is told first, because tearing down an SSH connection in front of that would be latency on the one thing the user is watching for. The dictionary entry stays, because LiveSessionCount, IsSessionLive and the tab's own ENDED and RECONNECT all read it. SendInputAsync therefore asks about liveness rather than presence, since the shell channel is gone the moment the remote is. Two faults introduced on the way here and fixed, both worth naming because neither would have been visible from the outside: The read loop left through a condition on its cancellation token, and RunAsync reads a read loop that returns as the remote having closed the channel. A tab the user closed would have reported itself as a shell that exited on its own. It throws instead. With the reader finished and a renderer that had stopped acknowledging, the flush loop span: output to send, no credit to send it with, and a wait for more output that returned at once because there was some. RunAsync never completed, so the tab was never told and the connection was never released — a session invisible and permanent. The loop now leaves for the drain as soon as the backlog is complete, and RunAsync resets the credit window alongside completing it so a flusher already parked on an acknowledgement that will never come is released. Four tests asserted the old contract and now assert its reverse, which is the point rather than a relaxation — WhenCreditRunsOut_ThePumpStopsReading is now WhenCreditRunsOut_ThePumpKeepsReadingAndTheBacklogHoldsTheLine, and has to prove both halves at once. The throughput harness gained a counting transport, because a consumer that copies every frame into a 32 MB list was the slowest thing in the pipeline and a harness whose consumer cannot keep up measures itself. The fake shell gained a pace, so "a renderer that keeps up loses nothing" can be asked of a producer a network could actually deliver rather than of one running at memory bandwidth. The test that would have caught this in the first place is ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog, and it needs the real SSH.NET and the real sshd to say anything: a fake shell hands over bytes when it is asked, and a real channel hands them over when they arrive. Under the old code its BytesRead would have stopped at exactly WindowBytes. 121 terminal tests pass over three consecutive runs with no flakes, 105 against the container, and the app, session, layout and remaining suites are unchanged and green. Manual checks 1.10a and 1.10b cover the two symptoms a person can see: a working set that plateaus under `yes`, and a connection that goes when the shell does rather than when the tab does. |
||
|
|
dec2cf164a |
Let the connecting card's host name and address be taken away
The card that fills the terminal's rectangle while a connection is being made prints two lines saying which machine and at what address, and both were TextBlocks — which in Avalonia is a drawing of a string rather than a string. It is the one place the shell says what it is dialling *before* anything has answered, and on a refusal it is the only copy of that on screen: there is no prompt yet and there may never be one. That is exactly when the value is wanted somewhere else — in a ticket, in a message to whoever owns the host, in the ssh command being written in the window beside this one — and retyping it off the screen is how a digit goes missing from an address that is mostly digits. SelectableTextBlock, the same change the session column's own address got in "Let the session address be selected, and copied whole" and for the same reason. Nothing else about either line moves: same font, size, weight, colour, trimming and centring. The refusal underneath them was already selectable, which made the pair above it the odd ones out. Both lines rather than only the name. They are two lines of one identifier, and a pair where the first can be taken and the second cannot is a worse answer than either. Tooltips are new, and they are what makes the trimming safe rather than a trap. A drag reaches only the glyphs that were drawn, so a name or an address wearing an ellipsis has to be taken with select-all — which takes the whole value, because the ellipsis is a drawing and not a shorter string. The hover says what the whole value is, so nobody copies half of one without having been told there is a half. Same argument, same shape, as the sidebar's. The test asserts that half rather than the easy one, on values long enough to be trimmed inside the card's 460 pixels; values that fit would pass whether the trimming were render-only or not. Confirmed it bites by putting the two TextBlocks back and watching it fail. The phone draws the same two lines in the same state — TerminalScreen.axaml's IsConnectingShowing block — and is deliberately left alone. Its captions are styled by TextBlock.title and TextBlock.detail selectors in Phone.axaml, so the same substitution there silently drops the styling until those rules gain SelectableTextBlock siblings, which is a theme change rather than this one. Worth doing; not worth smuggling in here. Also: WindowDecorations on the splash instead of SystemDecorations, which Avalonia 12 has deprecated in favour of it. That warning was mine, added two commits ago; the main window's own is older and is not this change's to move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fd92b80d4f |
Find out why a connection did not reach, instead of only saying it timed out
Measured against SSH.NET 2026.0.0 before anything was written, because the
premise needed checking. Four ways of failing to reach a host, and what this
client puts in front of the user for each:
server accepts TCP, never sends a banner -> "Connection has timed out."
packets black-holed, nothing comes back -> "Connection has timed out."
nothing listening on the port -> "...actively refused it."
name does not resolve -> "No such host is known."
The first two are the same five words and are not the same problem. One is a
network, a firewall or a wrong address; the other is a machine that is up and
well and an sshd that is wedged, or a port that is not SSH at all. Nothing in
the exception tells them apart, and that sentence was the whole of what this
product could say about the failure people hit most.
◆ A SOCKET ANSWERS IT, IN ABOUT AS LONG AS IT TAKES TO OPEN ONE.
SshReachabilityProbe resolves the name and opens a bare TCP connection, and the
four answers it can come back with are the four somebody would do something
different about — unresolved, refused, silent, open. Every one of them is what
a socket actually did, measured at the moment it was asked, rather than
anything inferred from the exception that started it.
Deliberately nothing else. No ping: ICMP is filtered on most of the networks
this is pointed at, so an unanswered one would be evidence of nothing. No scan
of neighbouring ports — the question is what happened to the connection the
user asked for, and probing ports nobody asked about is how a client gets
flagged by an intrusion detector on the very network somebody is trying to
work on. No second SSH handshake against a server that has already failed one.
The budget is six seconds and the floor under it is not a guess: a refusal is
not immediate on Windows, and a closed loopback port took ~2s to answer when
this was measured, because the stack retries the SYN first. At two seconds the
probe calls a machine that is up and refusing a firewall. The test for that
case runs at the real budget on purpose, so lowering the number fails it.
◆ ONLY WHILE REACHING, AND THE WINDOW IS CLOSED AT BOTH ENDS.
Past SshConnectionPhase.Reaching the machine has offered a host key, so it has
demonstrably been reached and a refused password is not a reachability
question. Before it, nothing has been dialled at all — a private key that will
not parse throws out of BuildConnectionInfo before the factory reports
anything — and probing there finds a healthy port and appends "the SSH
handshake is what did not finish" to a message about an unreadable key. That
is a confident wrong answer replacing an accurate one, so both ends are shut.
PhaseReporter keeps the furthest phase to decide it.
The finding is added to what SSH.NET said rather than replacing it: the
exception is the fact, and this is a reading of a second measurement.
Every shell in ShellFlowTests now gets a scripted probe. Without one a failed
connection in any test in that file would do a live DNS lookup for db.internal
and open a real socket — slow, network-dependent, and worse than either, since
db.internal resolves on any machine whose resolver has a search domain that
makes it, which would make the asserted sentence depend on whose network the
suite ran from.
◆ WHAT THIS DOES NOT FIX, FOUND WHILE READING THE SAME PATH.
- SshConnectionRequest.ConnectTimeout is documented, plumbed, and passed by
nothing. Every connection this client makes is hard-wired to the 15-second
default, and there is no per-host field and no setting that can change it.
Fifteen seconds bounds the handshake waits as well as the connect — a
server that accepts TCP and never sends a banner fails at exactly the
timeout, measured — where OpenSSH's own ConnectTimeout covers only the
connect. If timeouts are frequent on slow links, this is the first
suspect, and it needs a product decision rather than a patch.
- The known-host lookup in HostKeyGate.OnHostKeyReceived blocks SSH.NET's
message thread inside that same 15-second budget. The blocking is
acknowledged in the code; that it competes with the key exchange's own
timeout is not. On a cold or locked vault a slow read would surface as a
connection timeout, which is exactly the symptom above.
- ConnectThroughHostKeyGateAsync never unsubscribes HostKeyReceived on
success, so the gate outlives the handshake holding the connect-time
CancellationToken. If SSH.NET raises that event again on rekey it would
re-enter the blocking store read with a token whose source is long gone.
Not verified either way — it needs a real sshd and a forced rekey.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
dabc691c35 |
Keep the doorbell listening between callers, and stop a hung test taking CI with it
The test step ran for twenty minutes and had to be stopped by hand. Eighteen suites
had passed; DodoSSH.Client.App.Tests never reported.
◆ ON UNIX, A FRESH SERVER PER CONNECTION LOSES THE NEXT ONE.
The doorbell's accept loop built a NamedPipeServerStream inside the loop, served one
caller and disposed it. That is fine on Windows and quietly wrong on Linux, where a
named pipe is a Unix domain socket: disposing the server closes the *listening*
socket and unlinks its path, so a second launch that connected while the first was
being served has its connection dropped. Its Connect returned, its write succeeded
into a buffer, and nobody ever read it — the launch was told it had rung the
doorbell and the window never came forward.
Reproduced in a container rather than reasoned about, because the two platforms
disagree and this machine is only one of them. Three rings back to back:
one instance, Disconnect between callers -> 3 of 3 answered
a fresh instance per connection -> 1 of 3 answered
So there is one server for the life of the doorbell now, Disconnect()ed between
conversations, and the socket is never not listening. Which makes leaveOpen on the
reader load-bearing too: a StreamReader disposes the stream it was given, and that
stream now has to survive into the next iteration.
Two things I had wrong about this, both worth recording. Cancelling
WaitForConnectionAsync *does* unblock a pending accept on Linux — that was my first
theory and the container disproved it, so DisposeAsync awaiting the loop was never
the problem. And the app itself never hung: the lost ring is a launch that is
ignored, not a process that stops.
◆ AND THE REASON IT COST TWENTY MINUTES RATHER THAN TWENTY SECONDS.
await twice.Task.WaitAsync(TestContext.Current.CancellationToken);
That token carries no timeout. A ring that never arrives is then not a failing test,
it is a job that sits there — which is exactly what happened, and it is the more
expensive of the two mistakes because it turns any future bug of this shape into a
runner somebody has to go and stop. Both awaits are bounded now.
TheDoorbellAnswersMoreThanOnce keeps its shape deliberately: two rings back to back
rather than one and a pause, because the gap between accepts is the whole defect. A
test that waited for the first to be answered would pass against the broken version.
ci.yml had no timeout-minutes anywhere, on any job. Every job has a ceiling now, and
the test step has a tighter one of its own — ten minutes against a suite that takes
two — because a hang there is the likeliest of the lot and a step-level timeout names
the step instead of leaving a reader to work out which of fifteen was still running.
None of them is a performance budget; a build that legitimately grows past one should
have the number raised.
Verified both ways on Linux, in a container, against the committed tree and this one:
|
||
|
|
b8c94ecb73 |
Put a window on screen while it starts, and refuse the second one
Two things a desktop application is expected to do and this one did not. ◆ THREE SECONDS OF NOTHING. Measured on a debug build against a cold profile: the main window arrives 3.1 seconds after the executable does. Nothing was on screen for any of it — the icon had been double-clicked and the desktop looked unchanged. The main window cannot come sooner. It needs a view model, and the view model needs composition to have opened the SQLite cache, bound the terminal's loopback socket, asked the platform about a TPM and read the install layout off disk. Its own IsStarting card already narrates what happens after it appears; this is the part before, which no arrangement of that window can cover. So SplashWindow, and it is measurably the point: 668 ms to the splash, 3.1 s to the window that replaces it. Two mechanics are load-bearing rather than incidental. The splash *is* desktop.MainWindow for a moment. The lifetime shows whatever that property holds once OnFrameworkInitializationCompleted returns; a window merely Show()n there would have no lifetime attached, and under ShutdownMode.OnLastWindowClose the first close of anything would end the process. Handing the property over is one assignment and Avalonia reads it lazily. And composition is posted at DispatcherPriority.Background rather than called. It is synchronous and slow — which is the entire argument for a splash — so running it inline would block the very dispatcher turn that has to paint the thing. The window is SizeToContent="Height", which is about the failure and not the splash: CouldNotStart carries an exception's own message, and inside a fixed height a long one would wrap and be clipped at exactly the moment that text is all the window is for. A composition that throws is caught rather than left to the dispatcher, for the same reason — before this there was nothing on screen to abandon, and now there is, so an escaping exception would leave a window saying "Starting…" over a process that had stopped. Escape closes it, and only then. ◆ AND ONE INSTANCE. Claimed in Main before Avalonia is configured, so a second launch starts nothing at all: no toolkit, no window, no splash flashing up before it thinks better of it. Exclusion is an exclusive handle on instance.lock inside the profile directory, and that location settles two questions at once. A nightly and a release build do not exclude each other — separate profiles, ADR 0013 — and two people signed in to one machine each get their own instance. A global mutex would have had to re-derive both from somewhere. A file handle rather than a named mutex because the kernel releases it however the process ends. There is no such thing as a stale lock here, which is a state a named mutex makes both platforms model differently and neither model simply. The second launch is not silent. Somebody who double-clicks an icon and sees nothing has been told the application is broken, so it rings a named pipe — named from a digest of the profile path, so it is scoped exactly as the lock is — and the running instance un-minimises and activates. Activate is a request and not a guarantee: Windows refuses the foreground to a process that did not receive the input, and the flashing taskbar button that results is still an answer. Three seconds of retrying when the lock is held by something that does not answer its pipe, and that window has one job. Velopack's apply ends this process and its updater starts the new build; the two are ordered, but a lock held a moment too long would make the successor refuse itself and vanish — an update that looked like an uninstall. The doorbell is disposed before Shutdown for the same reason, so the pipe name is free before the process is. ◆ VERIFIED BY RUNNING IT, WHICH IS THE ONLY WAY EITHER OF THESE COULD BE. Two launches of the built executable: the first stayed up with instance.lock in the profile, the second exited. Window sizes over time are where the two timings above come from — 420x160 then 1196x776. SingleInstanceTests covers what a single process can reach: the lock, its release by disposal, the doorbell answering more than once, and two profiles not excluding each other. SplashCardTests measures the one thing the splash can get wrong, which is a failure message long enough to need the height the window is willing to give it. Checks 1.12 and 16.11 are what needs a second executable and a real screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c859705b83 |
Give the hosts board its vaults, and the Hetzner import a vault and a key
Six things, all of them answers to a question a screen was deciding on somebody's behalf. The board draws a heading per vault and sorts each vault's hosts by that vault's own groups. A group is an item of one vault, so drawing every group of every vault in one run flattened away containment that already exists: two vaults each holding a "production" were two headings with the same word on them, told apart by a small grey name beside each. Single- vault sessions are unchanged. There is now one "No group" heading per vault, so the folded set is keyed on SidebarGroupHeader.CollapseKey rather than on GroupId ?? Guid.Empty, which had one vault's unfiled hosts folding every other vault's away with them. A Ctrl-click starts its set with the card the board has lit. The board always has a selection — a fresh unlock lights the first host so CONNECT has something under it — so "one card lit, nothing ticked" is the state this screen opens in, and ticking a second card there produced a set of one: the machine the user was looking at was left out of the count and out of everything Delete, Move and Change group then ran over. The card tooltip says the host's name as well as its address. 214 pixels of card trims the name, and the tooltip was the one place it was not said in full. The card's floor drops from 120 to 104. A card with no tags measures about 87, so the mock's number was thirty pixels of air under every card on a board that is scrolled. "Move to another vault…" joins the board's context menu for one host. Moving one machine meant opening its pane and finding the menu inside it, while moving six was a right click away — the one-host case was the harder of the two. And the Hetzner import asks which vault and which key. A project belongs to a team more often than to a person, so forty servers landing wherever the session happens to be standing is the wrong default; ImportHostsAsync takes the target vault, and the ssh_config importer passes null and is unchanged. The key is the half Hetzner cannot supply and the keychain usually already holds — the public half went to Hetzner when the servers were made — so the import points every host at it rather than bringing one. It defaults to no key rather than guessing, and says so when the key it was given lives in a different vault from the hosts. |
||
|
|
ed936380b3 |
Give the user menu one door into settings instead of three
Settings, Vaults and Preferences all opened the same full-window settings mode and differed only in which of its seven pages they landed on. SettingsNav, which lists every one of those pages including Vaults and Preferences, is on screen the whole time you are in there — so two of the three rows were deep links into a place the third one opens. They saved one click and cost a menu that read as though it offered three destinations. It was three rows for a reason that had stopped applying. Until v5c, "Settings" and "Preferences" were two doors onto the same bare Preferences screen, and splitting them was the fix for something real; Vaults arrived beside them when VaultsScreen became a settings page too. Folding those two back in is not a return to that state. They are pages now, with a rail of their own, and what a menu at the foot of the nav rail is for is getting into settings at all. Nothing became unreachable — both are one click deeper, from the rail that was always going to be the way to them. ◆ WHAT STAYS, AND WHY IT IS NOT THE SAME ARGUMENT. Logout stays and still lands on the Account page rather than on General, because its confirmation card is drawn inline on that one page while the vault is unlocked; arming it from anywhere else would raise a card nobody could see. The vault switches and New vault above the divider stay too — neither is a page of the settings mode, and the switches have no other home. ◆ THE ROWS ARE REMOVED FROM THE MARKUP, NOT UNWIRED. NavRailTests asserts their absence rather than only asserting that Settings still works, because the failure that guards against is silent: a row left in the markup with its handler deleted is a menu entry that does nothing when pressed, which reads as a broken application rather than a tidied menu. The helper it asserts through excludes the vault switches by their data context and not by their label — a vault is free to be called "Vaults", and a test that a menu entry is absent must not be defeatable by what somebody named a vault. Two facts go, one comes back plus the absence check; the layout suite is 166. Manual check 1.8 walked a reader through all three rows in turn and now walks them through the one, then through SettingsNav's seven — which is where the two went. design-import-gaps.md gains a v5c-5 section rather than an edit to the v5c table: what that table records is what shipped in that pass, and it did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c270f3fe39 |
Import a Hetzner Cloud project's servers, and forget the token that read it
An estate on Hetzner is already described somewhere: the project lists every server, its addresses, its plan and where it runs. Getting that into a keychain meant copying forty addresses out of a browser by hand, which is the same work the ssh_config importer exists to remove for the machines described in a file. ◆ A WIZARD RATHER THAN A SCREEN, AND THE DIFFERENCE IS WHAT EACH STEP COSTS. ImportScreen reads a file that is already on this machine, so it scans on open and shows a list. This cannot: it needs a credential that has to be made in another company's console first, and then it makes a request over the internet. Those are two moments rather than one, and a screen that mixed the token box in with the list would ask for the credential again every time somebody came back to look at the list. So three steps — token, choose, done — and the first two write nothing. Step two carries one address choice, one login user and one port for the whole import, because an estate is reached the same way throughout and a per-server dropdown would be forty answers to one question. ◆ THE TOKEN IS USED ONCE AND CLEARED, INCLUDING WHEN IT WORKED. The half worth stating is the success path. Clearing only on failure would leave a credential capable of listing somebody's whole estate sitting in a text box for as long as they spend reading forty rows, which is the longest anybody stands on this screen. Nothing writes it to the vault, to settings or to a log, and reopening the wizard restarts at step one rather than resuming — a resumed step two would be a list nothing on screen has the means to refresh, going on looking current for as long as the application stays open. Read is the only permission it needs and the note above the box says so: a Read & Write token would work identically here and would be a credential capable of deleting the estate, pasted in for no gain. The note also says a token belongs to one project — there is no API call that will say which, and none that lists the others — so an account with four projects runs this four times. Without that sentence the wizard looks like it lost most of the estate. ◆ THE ADDRESS IS A CHOICE, AND TWO OF THE THREE FALL BACK WHERE THE THIRD MUST NOT. Public IPv4 and public IPv6 fall back to each other: the same machine over the same internet, so the worst case is a route nobody picked, and the row says which. Private does not fall back at all. A server on no private network is simply not importable under that choice — its tickbox is disabled, and neither direction of the header's tick-all can reach it — because falling back would put an address on the open internet into a bookmark that was asked to stay off it. IPv6 needs saying twice over. Hetzner routes a whole /64 and the API reports the network, which nothing can dial; the address a stock image answers on is the first one in it. That is a convention of Hetzner's own images rather than a fact the API stated, so every use of it carries a warning on the row and in the stored note, and it is computed by setting the low byte rather than by string surgery on the prefix. It is not an edge case either — IPv4 is billed separately now and a project built since then can be entirely IPv6-only. ◆ NOTHING IMPORTED HERE AUTHENTICATES, AND THAT IS SAID BEFORE THE BUTTON. Hetzner has never held the private half of an SSH key. The API will name a project's keys and hand over their public halves; there is no private half to bring. So every host arrives bound to nothing and asks for a password until somebody points it at a key already in the keychain — the same shape an ssh_config import has with its key tick off, and stated above the button on the same reasoning: somebody who is not told will conclude the import was broken. What the note keeps is what does not go stale — the id, the plan, the location, the image, the labels. The power state is a column and never a note: "running" about a machine somebody switched off last March is worse than no note. ◆ WHERE IT LIVES: TWO FLAGS OVER ONE CONTENT COLUMN. Drawn over the Preferences page inside settings mode exactly as ImportScreen is, with SettingsNav unmoved and the titlebar offering "Back to preferences". IsHetznerImportOpen is a second flag rather than a "which importer" enum, because they are two independent facts about the content column and a single value would need a "neither" member IsImportOpen already expresses; what keeps them from both being true is that every path that opens one closes the other. The titlebar and the Preferences page ask IsImporterOpen, which is written against both. Preferences grows a second importer row beside the first rather than one row that asks "from where?", which would put a question in front of the far more common answer. ◆ WHAT IS NOT HERE. Hetzner's Robot — dedicated servers and Storage Boxes — is a different API behind a different credential, and nothing in this touches it. Said on the Preferences row and on the token step rather than left to be discovered by an account that has both. And there is no phone layout. This one would work on Android, unlike the ssh_config importer beside it: it is a token and a network request, the view model is shared already, and scoped storage has nothing to do with it. What is missing is a screen — the desktop draws a five-column table, and the phone version of that is a different layout rather than the same one narrower. Recorded in android-port.md and on the phone's own Preferences screen, because the note there previously read as though no import of any kind could work. ◆ TESTS, AND WHAT IS LEFT FOR A PERSON. HetznerCloudClientTests is 16 cases over the shapes the API actually returns, against a stubbed HttpMessageHandler: paging followed, the token in a header and never a query string, a refused token distinguished from a fault at Hetzner's end, a token with a line break refused before anything is sent, the /64, the private-network asymmetry, and a server the API described without a name. ShellFlowTests.Hetzner.cs is 11 more against this fixture's real vault, real cache and real server — split into a file of its own, as FakeVaultServer keeps its team endpoints. What they establish beyond the reader's own suite is the half it cannot reach: that reading stores nothing, that importing goes through the ordinary create-and-push path and lands on the server with nothing pressing Sync, that a server the keychain already has arrives unticked, and that the token does not outlive the request. Two layout cases measure both steps against the same content budget every other settings page gets, which is also what proves the markup binds to names that exist. Manual checks 2.11a–2.11d are what no test in this repository can do, because there is no account, no token and no network in the ordinary suite: a real project listed against the real console, a wrong token, the private choice, and what an imported host does when you connect to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
56c19b006d |
Stop the updater offering the build it saw first, whatever has been published since
A client two versions behind was offered the older of the two, installed it,
restarted, and was told at once that another update was waiting. Two downloads,
two installs and two restarts to move one step.
The feed was not the problem and could not have been. The nightly release is
deleted and remade on every push to main, so it holds exactly one build: a client
can only ever be offered the newest thing there is. The wrong version came from
this application's own state.
◆ THE PASS STOPPED ASKING, WHICH IS NOT WHAT IT WAS MEANT TO STOP DOING.
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
Ready means something is downloaded and waiting. The intent behind excluding it is
right — there is no point fetching what has already been fetched — and what it
actually did was skip the check. So from the first thing any process downloaded,
its banner was pinned to that build for the rest of its life, however many newer
ones were published afterwards. On a rolling channel that is the normal case
rather than a corner.
Not re-fetching is still the rule and now lives in FetchAsync, which compares
versions rather than refusing to look: the same build is left alone, a newer one
replaces what was pending. Which is also why FetchAsync answers with what is
*pending* rather than with what the check returned — a client on 1.0 holding 1.1
is told 1.1 by every later check, because 1.1 is still the newest thing on the
feed, so "the check found something" and "there is something new" are different
questions and only the second one is worth a download.
A build already fetched is never discarded, including when a check answers null.
Null means the feed's newest is no newer than the build that is *running*, which
says nothing about the one on disk — that one is still newer, and withdrawing an
offer somebody has already been shown would be the worse answer.
◆ AND A LAST LOOK BEFORE THE PROCESS IS REPLACED, WHICH IS THE HALF THE TIMER
CANNOT REACH.
The loop asks every six hours. The build that caused this was published minutes
before RESTART NOW was pressed, so no interval anybody would accept would have
caught it. The press now asks once more.
It costs one small request in the ordinary case, because the feed answers with the
build already on disk and nothing is fetched. It costs a download only when there
genuinely is something newer, which is the case where the alternative was two
downloads anyway.
Bounded at ten seconds, and the wait rather than the request: VelopackUpdateChannel
records that Velopack's check takes no token, so cancelling would be observed only
after the call had already returned. Ten seconds bounds a small JSON document
against a network that drops packets rather than refusing them; a refused
connection fails in milliseconds and never reaches it.
Nothing here refuses a restart. An unreachable forge, a look that ran out of time,
a fetch of the newer build that failed halfway — all leave the build on disk
untouched and still newer than the one running, so the restart goes ahead with it.
Hence swallowed rather than reported: Failed is for something a person is waiting
on an answer to, and this person is waiting on a restart they are about to get.
IsRestarting keeps the banner up through it. Everything the strip draws keys off
IsReady and the look moves the state off that, so without it pressing RESTART NOW
made the banner vanish with nothing on screen saying why, for as long as a 120 MB
package takes. It says what it is doing instead, percentage and all.
◆ THE CHANNEL HAD TO LEARN WHICH BUILD IT FETCHED, AS AGAINST WHICH IT LAST FOUND.
Required by both halves above rather than tidied along with them. Matched()
resolved an AvailableUpdate only against `found`, which a check overwrites — and
both changes make this application check while a build is already downloaded. A
check that lands a newer answer and then fails to fetch it, or is given up on
halfway, leaves the shell restarting with the build it already has and Matched()
throwing at it: the offer real, the package on disk real, and the only thing wrong
being that the channel had forgotten which one it was. A slow forge became a
refused restart.
So `fetched` is recorded after a download returns, tried before `found`, and
survives every later check.
Settings → General claimed checks start "a couple of minutes after launch", which
has been untrue since the first pass moved to launch. It now also says the loop
keeps asking while an update is waiting, because that is the behaviour somebody
reading that row would want to know about.
UpdateFlowTests goes from 21 to 28: the pass asks again and fetches once, the
pending build is replaced when the feed moves on, a superseded offer reappears
through a dismissal, the press installs what the last look finds, and the three
ways the last look can fail all still install what was already on disk. Check
16.7a is the manual half, needing two real releases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1908189100 |
Make the terminal's right button copy and paste, and never open a menu
Selecting output and right-clicking it opened the WebView's own context menu with Copy on it, which is correct behaviour for a web page and wrong for a terminal. What a terminal's right button has meant since the Windows console got QuickEdit, and since PuTTY shipped, is this: with something selected it copies and drops the selection, with nothing selected it pastes. No menu, no second click. The gesture can only live in the page. The selection is drawn into a canvas the host never sees, and once a terminal has focus the host's window receives no input events at all — the same reason the Ctrl+plus chords are handled there. ◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY. navigator.clipboard.readText() is gated behind the clipboard-read permission in every Chromium, and an embedded WebView with no PermissionRequested handler answers a permission request by putting a prompt on the screen. Trading a context menu for a permission dialog is not this change. So the page asks and the host answers, exactly as requestFontSizeStep does. Writing does not go the other way either, although navigator.clipboard.writeText would have worked from the page on Windows: a clipboard that is the host's in one direction and the page's in the other is two mechanisms to keep working across WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate since the keychain grew its COPY buttons, so each App.axaml.cs grows a ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it. Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard. ◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING. Paste carries a snippet off the sidebar, and the page deliberately *types* a single-line one rather than bracketing it, because bash marks a bracketed paste as an active region and a snippet should read as though it had been typed. A clipboard paste is the case those markers are for — readline uses them to decide it has been pasted into — so Clipboard is always bracketed and never followed by an Enter. One opcode for both would have needed a flag meaning "which of the two kinds of paste is this", on a payload whose existing flag byte means something else. Both client frames are dispatched before the session lookup, which is what keeps the gesture working in a pane whose shell has just ended: squinting at the message a remote left behind is exactly when somebody copies the line. Dispatch was past MA0051's length once it grew a second such branch, so the frames no pump needs to see moved into Announce() and the font-size chord went with them. ◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED. preventDefault alone suppresses the menu and still lets xterm's own contextmenu handler run, which moves its helper textarea under the pointer at z-index 1000 and leaves the selection sitting in it, selected. Both of those exist to serve the native menu. Taking the event in the capture phase on the pane and stopping it there is what keeps the gesture wholly the page's. rightClickSelectsWord is now false, which on macOS is a change of default rather than a restatement of it: xterm sets it true there alone, so a right-click would have selected the word under the pointer and copied that instead of pasting — wrong on one platform only, which is the worst way for it to be wrong. The renderer harness grows the pane it has been inviting since RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec, because both directions run through the page's own TextEncoder and TextDecoder and a Latin-1 stub would pass a test with an em dash in the selection. Its warning about declaring names terminal.js declares now covers the half that does not SyntaxError — a harness frame() was silently replaced by the page's frame() and failed six tests on a type error a long way from the cause. Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu event reaches a capture listener, that no native menu is drawn once it is prevented, and that xterm's real paste() brackets. 11.13 also records what is unverified — whether Android's WebView delivers a pointer's secondary click as a contextmenu event at all. ClipboardReader is wired on that head regardless, because the alternative is a head where the paste half silently does nothing and nobody finds out; that head is not compiled here either, this machine having no android workload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
06369274dc |
Let the session address be selected, and copied whole
The address at the head of the session column is the one place the desktop head prints the endpoint it actually dialled, and it was a TextBlock — which in Avalonia is a drawing of a string rather than a string. The next thing people do with an endpoint is paste it somewhere: into a second client, into a ticket, into the ssh command being written in the window beside this one. Retyping is how a digit goes missing from an address that is mostly digits. SelectableTextBlock, which is what the rest of this application already reaches for wherever a string is on screen to be taken away — a fingerprint, a public key, a recovery code, a snippet's command. Nothing else about the row moves: same font, weight, colour and trimming, same tooltip, same close button beside it. The sidebar is one control drawn on both surfaces, so SFTP gets it for the same reason it gets everything else in that block. The trimming and the selection do disagree at the long end, and the fix for that is not to drop the trimming: the ellipsis is there because a long account@host:port would otherwise push the close button off the edge of a panel whose whole point is that it can be got rid of. A drag reaches only the glyphs that were drawn, so a trimmed address has to be taken with select-all — and select-all takes the whole thing, because the ellipsis is a drawing and not a shorter value. That is the half a test can quietly lose, so it is the half the new test asserts, on the longest address the suite has rather than on one that fits and would pass either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cfc2e3ae0c |
Notice when a file session stops, and give the file browser the four things it was short of
The commit before this one taught a terminal to notice its machine going away.
The file browser had the same defect and three of the same symptoms, and it is
also the screen people leave soonest — so the disconnect work and four things it
was missing arrive together, because they are the same complaint: this screen
knows less about the host than the host does.
◆ THE DISCONNECT HALF, WHICH IS THE OTHER SURFACE OF THE LAST COMMIT.
IsConnected was set at connect and cleared only by the DISCONNECT button. A host
that went away therefore left this screen saying CONNECTED over a session that
was gone, and every action after it failing with the same server message and
nothing saying why they all did.
IRemoteFileStore now carries a latched TransportFailure and a ConnectionLost
event. The latch is the shell's own — read ISshShellSession.TransportFailure for
the argument, which is unchanged. The event is not, and it is the part this
surface needs: a file browser is idle between operations, so without something
raised at the moment the transport fails, nobody finds out until they click. A
bucket implements both and raises neither, which is honest rather than a gap:
S3 is request-per-operation with no connection to lose.
- THE LISTING STAYS ON SCREEN, and that is the half that makes the rest worth
having. SftpEntry is a record of what the server said rather than a handle,
so the pane can go on showing where the user was — which is what RECONNECT
is offering to put them back into. That needed a new ShowsRemoteContents on
both heads: every "is there anything here" binding read IsConnected, which
was right while the only way to stop being connected was to press a button
that empties the pane, and wrong the moment a session could be lost.
- RECONNECT re-dials through the ordinary connect path — same host key gate,
same refusals, same log entry — and then walks back to the directory the
pane was showing. The path is read off RemotePath rather than captured,
because the pane already keeps it up to date and a copy is a second thing to
get wrong. A host wanting a typed password is asked for one rather than
dialled with an empty box.
- SessionEndedBar is now one control on both surfaces, with the shell
resolving which it is drawn over: a terminal has two endings to tell apart
and a keychain entry to check, a file session has one ending and already
captured what it would dial. The phone's Files screen has its own copy,
bound to TransfersViewModel directly, because its DataContext is that rather
than the shell.
◆ PERMISSIONS: THE PERMS COLUMN STOPS BEING READ-ONLY.
It has been a true fact about a file with nothing to be done about it since it
arrived, and the commonest next act after using this screen was opening a
terminal — a whole second authenticated connection — to chmod what had just been
uploaded.
Nine checkboxes rather than a box to type 755 into, because the mode is already
drawn as -rwxr-xr-x in the column the form was opened from: a who-can-do-what
grid is the same representation the reader is holding, where an octal field asks
them to translate something they can see into something they cannot check. The
octal is printed underneath as it changes, so the two readings check each other.
The EXEC column is headed "enter" over a directory, which is what that bit means
there and the commonest misreading of a POSIX mode. The listing is re-read after
APPLY, because a umask, an ACL or a filesystem mounted without permissions can
all land somewhere other than what was asked for.
Beside it, mode and mtime carried across a transfer — which is what makes an
uploaded deploy.sh arrive executable instead of with the server's umask on it.
THE MODE IS ONLY SENT FROM A PLATFORM THAT HAS ONE. A file on Windows has no
POSIX mode; this screen already says so by leaving the local pane's PERMS column
empty rather than filling it with a plausible -rw-r--r--, and inventing 0644, or
0755 for anything called .sh, would be this client deciding a fact about
somebody's file that nothing on the machine it came from ever said. The
timestamp goes either way, because every platform has one. A store with neither
refuses in its own words and is never asked.
◆ MULTI-SELECT: A TICK IS NOT THE LIST'S SELECTION.
The two answer different questions and the split is the whole design. The
selection is "the row I am looking at", which rename, move and permissions act
on — each takes a form naming one path and is one-at-a-time by construction. The
tick is "the rows I have gathered up", which download, upload and delete take,
and it survives the highlight moving.
With nothing ticked the batch falls back to the selection, which is what keeps
the commonest use of this screen from growing a step — the usual way a
multi-select makes a tool worse. Ticks are released once a set is queued, or a
second press sends everything again.
Deleting several names every path rather than a count, because agreeing to "3
items" is agreeing to a number, and this is the one act on this screen that
nothing walks back. Each goes on its own and a refusal does not stop the rest:
the host turns down a directory that still has something in it, and in a set of
five that must not silently cancel the four it would have removed — nor hide
which ones it did not.
A drawn checkbox rather than ctrl-click, and not for taste: the phone shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a
tap target there whatever the desktop does. One shape on both heads, and a state
somebody can see.
◆ RECURSIVE FOLDERS: THE TREE BECOMES FILES BEFORE THE QUEUE SEES IT.
A directory used to be counted and skipped. It is now walked, and the queue
still knows nothing about trees — which is what keeps every property it has:
each file resumable on its own, each written to a part file and renamed into
place, none able to overwrite anything already there.
The plan is collected whole and then queued, because directories have to exist
before the files that go in them and shortest-first is the only order that
guarantees a parent before its child. It also means a walk refused halfway has
queued nothing yet, so the sentence it produces describes one outcome rather
than an outcome and a queue that half happened.
Links are counted, not followed, in both directions. One pointing at an ancestor
is a walk with no end, and on the remote side there is a second reason: an lstat
listing says a name is a link and not what it leads to, so descending would be a
round trip per entry to find out. Locally that is AttributesToSkip on the one
enumeration that decides what to descend into. A 5000-file ceiling is a backstop
for the accidental drag of a home directory, and it says so rather than
truncating quietly.
The status line grew four more ways to be partly what was asked for, and states
each: gone, refused, left alone, not a file.
◆ EDITING IN PLACE: FETCH, OPEN, WATCH, PUSH BACK.
The round trip this replaces is download, find it, open it, edit, come back,
delete the old one because nothing here overwrites, upload, check the mode
survived.
DELIBERATELY NOT THROUGH THE TRANSFER QUEUE, in either direction. The queue
refuses to overwrite, which is the right rule for a file somebody dragged and
exactly the wrong one for a file they are editing, where replacing the original
is the entire intent. The write-back deletes first: OpenWriteAsync at offset
zero writes over the beginning and leaves any tail that is longer — its own
contract says so — which for a config that got shorter means a file ending in
the back half of its previous version. Deleting is the only truncation this
interface offers, and the mode goes back on afterwards because deleting took it.
A stat before every save, compared against what the server said when the copy
was taken, catches somebody else writing while the file is open — which is how a
config gets silently reverted. There is no merge and no button pretending
otherwise; the three answers offered are the three that exist. Length and
timestamp rather than a digest, because a digest is a second full read of a file
there is otherwise no reason to fetch.
Both platform halves are injected: what opens a .conf is a registry
association, a desktop entry or a Launch Services binding depending on the
desktop, and watching a file is a platform object with its own thread. The
watcher watches the directory and filters the name, because a great many editors
write a temporary file and rename it over the top — which to a watcher pointed
at an inode is the file disappearing. The phone gets neither delegate, so the
button is not live there rather than there and disappointing.
◆ TWO THINGS FOUND ON THE WAY, BOTH WORTH STATING.
The shell suite's inline `post` was not serialising the way a dispatcher does.
Routing TransfersViewModel through it exposed a check-then-add in
OnTransferChanged that two threads turn into two rows for one transfer. Harmless
in production, where the UI thread serialises — the fixture was under-specifying
what it stood in for, and now posts under a lock.
And the edit session replaced its own record with `with` on each save, which
left the watcher holding an instance that was no longer the current one: every
save after the first quietly did nothing. The stamp moves in place now, and the
test saves twice.
◆ WHAT IS VERIFIED.
901 across the five suites this touches, 19 of them new. The layout suite earned
its keep three times over: the batch deletion card's path list overflowed the
204-pixel pane at 120, at 72 and at 56 before 32 held, and the edit strip
overflowed by 131 until the whole strip — rather than the log inside it — was
bounded. Each number in those files is measured and says which failure it came
from, because the next person to add a line to either card will move them again.
What no suite covers is the platform halves of editing: Process.Start with
UseShellExecute and a live FileSystemWatcher are the operating system, and a test
waiting on one would be measuring it. The shell's own half — when a save is
written back, when it is refused, what stopping releases — is covered against a
watcher that fires on demand.
|
||
|
|
e2733e5cbc |
Notice when a shell ends, say which way it ended, and offer the way back
A terminal whose machine rebooted stayed green for ever. Not "went stale and
recovered late" — for ever: the tab claimed CONNECTED, the elapsed timer went
on counting, and the only thing that ever corrected it was closing the
application. That is the defect this fixes, and it turned out to be two
separate failures wearing one symptom.
◆ THE HANG IS IN SSH.NET, AND KEEP-ALIVE ALONE WOULD NOT HAVE FIXED IT.
ShellStream ends a blocked read from exactly two places: its channel being
closed, and the session raising Disconnected. A shell that exits gives the
first. But Session raises Disconnected from one method only — OnDisconnectReceived,
for a server that politely says goodbye — and a socket that simply fails goes
down a different path entirely: RaiseError fires the error event, sets the
disconnecting flag and disposes the socket, without ever raising Disconnected.
So the stream was never disposed, its Monitor.Wait was never pulsed, and the
read blocked until the process ended. The transport was gone, the client knew
the transport was gone, and nothing woke the one thread that was waiting.
That is why the first attempt at a test for this sat at thirty seconds and
failed. Both halves are needed and neither is sufficient:
- Keep-alive makes the failure HAPPEN. An idle SSH session is a socket
nobody writes to, and TCP has no way to discover that the far end has gone
without writing to it — nothing was asked, so nothing could fail. Thirty
seconds, matching OpenSSH's own ServerAliveInterval examples. It is the
write that matters and not a reply: SSH_MSG_IGNORE need not be answered,
so this is traffic rather than a heartbeat with a deadline, and there is
no timeout invented here to be wrong about a slow link.
- Disposing the stream from the error handler makes it ARRIVE. Queued to the
thread pool and not done inline, which is not tidiness: this runs on
SSH.NET's message-listener thread, and closing a channel waits for the
server's close — processed by that same thread — so an inline dispose is
the listener thread waiting on itself.
SFTP gets the keep-alive too. Its operations are request/response and bounded
by ConnectionInfo.Timeout already, so it needs no equivalent wake-up.
◆ exit AND reboot ARE TOLD APART BY A LATCH, BECAUSE THE OBVIOUS WAY IS A RACE.
Wait for the read to end and then ask whether the transport is still up, and
against a real server the answer is wrong about half the time: this was the
only channel on the connection, so sshd hangs up a millisecond after closing
it. By the time anything could ask, the transport is gone either way — and a
poll would report every ordinary `exit` as a dropped connection.
So ISshShellSession.TransportFailure is set at the moment SSH.NET raises the
failure, which its own ordering puts strictly before the disconnect that wakes
the read. A read that has just returned zero consults a settled value: set if
the transport failed, still null if the remote merely closed the channel. The
pump turns that into a TerminalSessionOutcome — RemoteClosed, ConnectionLost,
ClosedLocally — carried on SessionEnded, which used to hand over a session id
and nothing else. A subscriber cannot work this out afterwards; by the time it
hears, there is no transport left to interrogate.
Deliberately no fourth value for "the remote rebooted". Nothing on the wire
distinguishes a reboot from a cable being pulled, and a name this client
cannot check would be a guess dressed as a fact.
◆ WHAT IS DRAWN, AND WHY IT IS NOT DRAWN OVER THE PANE.
The status bar says DISCONNECTED or SHELL EXITED where it used to say NOT
CONNECTED for both — a word that is honest about a tab still dialling and
useless about one whose shell has stopped. Red for the first and grey for the
second: one is a machine that stopped answering and the other is work
finished, and crying wolf on the ordinary end of every session is its own bug.
The tab strip's dot gains the same fourth state, on both heads.
Under the terminal, a strip carrying the reason and two buttons. It takes its
own Auto grid row rather than overlaying the renderer, and that is forced
rather than chosen: the WebView is a native child view composited above
everything Avalonia draws, so the alternative is collapsing the terminal — and
the scrollback is precisely what the user wants to read, because the last
thing the remote said is usually why it went. terminal.js already writes the
same sentence into the pane; this is the copy that does not scroll away.
RECONNECT reuses the tab in place, which is what the word means to somebody
who pressed it: the terminal they were working in comes back, where it was.
It dials through the vault's ordinary connect path, so every refusal — a key
that has left the keychain, an unknown host key, a group default that has
changed since — is decided in the one place that already decides them; what
this adds is which tab the attempt belongs to. The old session is closed as
part of it, because a dead shell still holds an authenticated connection this
process has stopped reading, and its pane still holds an xterm, a scrollback
and a WebGL context, of which a browser hands out about sixteen.
Two refusals it makes rather than pretending: a host reached by typing an
address has no keychain entry to redial, and one that wants a password typed
goes to the keychain with the sheet raised rather than being dialled with an
empty box. Both say so on the strip where the button was pressed, not only in
the vault's status on a screen that surface is not.
TRY AGAIN appears on a refused connection for the same reason, and it is worth
as much: the commonest refusal is a host that was not up yet.
◆ WHAT IS ACTUALLY VERIFIED.
Two tests against a real sshd in a container. `exit` is reported as the remote
closing the session — the one a polling implementation fails, and a fake shell
cannot show it because a fake has no server behind it deciding to hang up. And
a live shell losing its transport is reported as a lost connection, produced
by dialling through a plain TCP relay the test owns and then cutting it; the
container is shared by the collection, so killing sshd was never an option.
The first draft of that second test used LoopbackProxyTests' SOCKS forward and
timed out: disconnecting the carrier of a ForwardedPortDynamic does not close
the loopback socket it accepted. Recorded in the test, because it is a
property of SSH.NET's forwarder rather than of anything here, and it is not
what a dying machine looks like.
What no test reproduces is the waiting. A relay that closes sends a FIN, so
the socket reports itself at once; a machine switched off sends nothing, and
finding that out is the keep-alive's business and the OS's retransmit budget's
— neither of which a test can hurry along without a clock this code does not
own. A reboot is prompt in practice because the machine comes back and RSTs.
Beside those: three pump outcomes, three workspace announcements, and seven
shell-flow tests covering the drop, the clean exit, reuse of the tab, retry
after a refusal, and both ways a reconnect can honestly come to nothing.
Also fixed in passing: SshNetShellSession.IsOpen read ShellStream.CanRead,
which is documented as always true — the stream stays readable after disposal
so buffered output can be drained — so it had answered "yes" for every shell
that ever existed. It reads CanWrite now. And MainWindowViewModel's session-
ended handler moved from Dispatcher.UIThread.Post to the injected post
delegate, which is what makes any of the shell half testable at all; the
remark listing the three unreachable posts is one shorter.
|
||
|
|
c2c860b4f2 |
Give the seated user the TPM, so nobody has to join the tss group
ADR 0007 decision 4 refused this a commit ago, on the grounds that widening
access to the machine's TPM is a system-wide decision an application package
should not make for an administrator. That refusal is overturned here, and the
reasoning it is replaced with is narrower rather than louder — because the
refusal had treated "widen access" as one thing when it is two.
Adding a person to `tss` is what the refusal was really about, and it stays the
wrong answer: permanent, effective over SSH, applying whether or not they are at
the keyboard, and needing an administrator and a fresh login before the feature
appears at all.
Tagging /dev/tpmrm0 with `uaccess` is a different thing. It is systemd's own
mechanism for this exact question — the one /dev/dri, webcams, scanners and FIDO
keys already use — and logind grants the ACL to the user of the *active local
session*, then takes it back when that session ends. Not over SSH, not on an
inactive VT, not to daemons. The rule changes neither the mode nor the group, so
everything that worked before still works and the ACL is layered on top.
tpmrm and never tpm, which is what makes it defensible at all: /dev/tpm0 is the
raw device and is exclusive, where /dev/tpmrm0 is the kernel's resource manager,
which virtualises handles and isolates callers precisely so that more than one
unprivileged program can use the chip.
◆ THE COST IS A DENIAL OF SERVICE AND NOT A DISCLOSURE, AND IT IS WRITTEN DOWN.
TPM objects are protected by their own auth values, so this lets nobody read
anybody else's sealed data. What it allows is *failed* attempts — and a TPM
answers enough of those by locking out everyone, which is the same anti-hammering
this project relies on to make a four-digit PIN safe. On a machine that unlocks
its disk with the TPM at boot, somebody sitting at it can trip that and stop the
next boot from unlocking until it recovers.
An administrator who does not accept that masks the file:
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
which is the standard override, survives upgrades, and puts DodoSSH back to
behaving as though the machine had no TPM. That is in the rule's own comments,
because the person who needs it is reading the file rather than the ADR.
Numbered 71 because 60-tpm-udev.rules is what creates the node's ownership and
73-seat-late.rules is what turns the tag into an ACL; verified in that order on
Fedora 44, along with `udevadm verify` accepting the file and the mask being
reported as "masked by previous entry". %post reloads and replays it against the
tpmrm subsystem, because a rule that waits for a reboot is a feature that does
not work on the day it is installed.
Both channels install the same path deliberately: this is about the machine's
TPM rather than about either build, the file is byte-identical from both, and
giving each its own copy would mean removing one package silently revoked the
other's access.
Phase 19 grew a check that the ACL exists for the local session and does *not*
exist for an SSH one, and another that masking the rule really does put the
machine back — then tells you to undo it before the checks that follow.
Also recorded, after it cost five confusing failures: swtpm serves exactly one
client, so a shell readiness probe sitting on the port makes the whole device-key
suite fail with "The TPM closed the connection" and nothing pointing at why.
|
||
|
|
4552538533 |
Let the Linux head check for updates, and seal its device key in the TPM
Two things the RPM shipped without, and the reason they arrive together is
that both are the same question — what may this platform ask of the machine,
and what must it not ask of the user.
◆ UPDATES: THIS CLIENT CHECKS AND FETCHES, AND PACKAGEKIT INSTALLS.
ADR 0013 rule 11 said there was no updater at all, and that went too far. It
conflated noticing that a newer build exists with installing it: the second is
what the trust argument is about, and the first is not — a client that checks
a fixed address and tells you what it found has taken no capability from
anybody. Written as a flat refusal, the rule bought its safety by paying a cost
that was avoidable, and the ADR now says so in its own words.
So LinuxUpdateChannel reads the rolling nightly release, compares versions the
way rpm compares them, downloads the package, verifies its digest and hands the
file to PackageKit over gdbus. Polkit asks; the user may say no; nothing is
torn down before the answer, because ApplyingEndsTheProcess is false and the
shell already knows what that means — it is the phone's shape, and declining an
update must not cost somebody their open shells.
Three details that are load-bearing rather than incidental:
- The comparison is rpmvercmp and not SemVer, because rpm is what will accept
or refuse the install. Same argument ADR 0014 makes for versionCode. The
installed side is read out of the rpm database rather than derived from the
assembly, so the SemVer-to-rpm translation exists in exactly one place. All
28 expected values in RpmVersionTests came out of rpm itself rather than out
of a reading of the algorithm.
- The digest is checked and is not a signature. Velopack verifies its own
downloads and Android checks a signature; an RPM over HTTPS has neither. The
manifest names a SHA-256 and a mismatch deletes the file rather than leaving
something installable-by-hand in the profile directory.
- ONLY_TRUSTED is cleared, which is what routes the request to polkit's
package-install-untrusted action. The package is unsigned, so requiring
trust would fail every update at the last step.
◆ THE DEVICE KEY: SEALED IN THE TPM, BEHIND A PIN THE TPM CHECKS.
ADR 0007's amendment is the bar: a gate inside the process is not a gate. The
key is sealed into a TPM 2.0 keyedhash object whose userAuth comes from a PIN,
so unsealing is a command the chip refuses; the blob is encrypted to the storage
hierarchy and is useless on any other machine; nothing here ever compares a PIN.
The sealed object deliberately carries no NoDA attribute, and that one omission
is the whole reason four digits is enough: every wrong answer advances the TPM's
dictionary-attack counter and the chip stops answering entirely once it trips.
Measured at four wrong tries against a software TPM, after which the correct PIN
gets nothing either. It is asserted by a test rather than left to a reading of
the template, because removing the attribute leaves every other assertion
passing.
This is weaker than Hello and Touch ID and the difference is invisible, so it is
written down in three places rather than smoothed over: those raise their prompt
from inside the platform and this one is a window we draw. The prompt is
deliberately plain for that reason — teaching people that a convincing DodoSSH
PIN box is a system dialog is the one thing this design must not do.
Rejected, each for failing the amendment: the Secret Service API, which has no
per-use consent and is DPAPI's shape exactly; fprintd, which answers a question
and holds nothing, so skipping the call skips the gate; and a polkit-gated root
helper, which would work and puts a privileged component in a package that
installs nothing setuid.
Availability is narrower than "has a TPM": on Fedora /dev/tpmrm0 is root:tss and
desktop users are not in tss, so the ordinary case reports unavailable and keeps
asking for the passphrase. Shipping a udev rule to widen that is refused — it is
the administrator's decision, not an application package's.
◆ WHAT IS ACTUALLY VERIFIED, SINCE THAT IS THE PART THAT USUALLY ISN'T.
This is the only device key store in the repository with real coverage, and it
is only possible because the PIN is a delegate and the TPM is a device: seal,
unseal, wrong PIN, lockout, decline and a foreign blob all run unattended
against a software TPM. Windows and macOS cannot be tested at all — their
prompts block a suite forever, which WindowsDeviceKeyStoreTests records.
TSS.Net's own TcpTpmDevice cannot reach swtpm: it opens the platform socket too
and exchanges a handshake only the Microsoft reference simulator answers, so
Connect() blocks with no error and no timeout. SoftwareTpm speaks the data
channel and nothing else.
The suite also lied once and was fixed: the lockout test trips shared state, and
without a DictionaryAttackLockReset in Dispose every later test got null from a
correct PIN — six of them assert null and passed for entirely the wrong reason.
End to end on a real Fedora 44: a genuine linux-x64 publish packages, installs,
and every DT_NEEDED in the installed tree resolves except liblttng-ust, which is
the one this spec deliberately excludes. What no runner covers is /dev/tpmrm0,
PackageKit and polkit, and Phase 19 grew five checks for exactly those.
Also fixed: the magic-bytes literal picked up raw control characters, which made
LinuxDeviceKeyStore.cs binary to git — no diffs and no line-ending normalisation.
|
||
|
|
b2f93a172b |
Give the file listings' columns their content back
The columns in both panes were fixed pixel widths, and measuring them against what they actually draw found them wrong at both ends of the range this screen gets. ◆ MODIFIED WAS TRUNCATED AT EVERY WIDTH THE APPLICATION HAS EVER BEEN DRAWN AT. It prints a sixteen-character timestamp, which needs 116 pixels, and it had 52. On a maximised window that read "2026-08" beside a NAME column with four hundred empty pixels in it. PERMS needs 60 for a ten-character mode and had 48; SIZE needs 58 for "999.9 GB" and had 56. ◆ AND AT THE SESSION SHELL'S OWN NARROWEST, NAME MEASURED ZERO PIXELS. Twenty for the glyph plus 56+52+48 for the numbers is 176 of the 180 a row has, so the remote pane drew sizes, timestamps and modes for rows whose names were not on screen at all. That is the same fault as the one above rather than a second one: the numbers were picked for the narrow budget, could not have fitted their own content there, and never grew anywhere else. Star columns with a MaxWidth answer both, because the cap is what makes "share the width" mean the right thing at each end. Wide, the three numeric columns stop growing at what their text needs and NAME keeps every remaining pixel. Narrow, nothing carries a MinWidth to overflow with, so all four give ground together and NAME — weighted heaviest — is the last to run out. MODIFIED takes the extra weight so it reaches its cap first, being the widest content and the column most often actually read; the local pane's NAME carries the remote's PERMS weight folded into it, which is what makes SIZE and MODIFIED come out the same width on both sides of the arrow column. Timestamps.Format already argued that both panes write a time the same way so the two can be read against each other, and two different widths would have made that argument for nothing. ◆ THE HEADINGS RAN TOGETHER, and that was visible as one word: SIZE ended on the pixel MODIFIED began at, so the row read "SIZEMODIFIED". They are right-aligned in their own columns, so the gap has to come from a left margin on each — which does not move the label and does stop the column before it reaching this one. The three now carry one, and ellipsize rather than clip where even the label has no room. ◆ TWO PLACEHOLDERS WERE CUT OFF MID-WORD, which reads as a rendering fault rather than as a hint. MKDIR's box drew "new directo" — 101 pixels of text in 74 pixels of room — and says "directory" now, since the button beside it carries the "new" half already. The pane header's path box drew a path ending mid-segment at the narrow budget and says "path": the only time that placeholder shows is on a pane with nothing connected, where the box is disabled and the invitation below is doing the explaining, and what the box is for is in its tooltip where there is room for it. FOUR COPIES OF THE COLUMN DEFINITIONS, one heading row and one row template per pane. Avalonia cannot share a ColumnDefinitions instance between two Grids — each mutates its own computed sizes — and SharedSizeGroup only applies to Auto columns, so what catches a copy that drifts is measurement rather than discipline: every cell is held against the width its own text needs when unconstrained, the headings against each other, and NAME against zero at the narrowest budget. Nothing asserts a number this file also wrote down, which is the failure mode a test of a layout most easily has. The roomy end is measured at the width a maximised 1843-pixel window leaves, which is where this was reported from; LayoutHarness.SessionScreenWidth cannot express it because that constant is pinned to the window's minimum, so the arithmetic is spelled out beside the test that uses it. |
||
|
|
882f01c515 |
Type a path on the SFTP pane, and put a file right without deleting it
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 23s
ci / android head (push) Successful in 3m27s
ci / desktop nightly (push) Successful in 53s
ci / build and test (pull_request) Successful in 2m30s
ci / android head (pull_request) Successful in 7m21s
ci / build and test (push) Successful in 2m28s
ci / api image (push) Successful in 36s
Three things the file browser could not do. Somewhere to go had to be walked to, a file that landed under the wrong name could only be removed and sent again, and the listing went on showing the directory as it was before this application put something in it. ◆ THE PATH IS A BOX NOW, not a TextBlock printing where the pane was. A breadcrumb only reaches what is under the directory already listed, so crossing a host's filesystem cost a click and a round trip a level, and a path somebody already knew could not be entered at all. Enter goes; Escape puts back what the pane is really showing. What is typed may be absolute, relative to the directory showing, or start at ~ — the four shapes a shell takes, because somebody typing into a box is typing what they would type into a shell. SftpPath.Resolve turns all of them into one absolute path, and it resolves them here rather than handing a relative path to the server: the server would resolve it against the session's own working directory, which is not necessarily the directory the breadcrumb above the box is showing. `..` is collapsed textually, which differs from cd through a symbolic link; the alternative is a realpath round trip before anything lists. The box holds its own value rather than RemotePath itself. Binding it straight to the path would navigate on every keystroke — `/`, then `/v`, then `/va` — a round trip and a failed listing per character. Every other way of moving writes the real path back into it; a refused listing deliberately does not, because the path that did not resolve is the one worth having in front of you. ◆ RENAME AND MOVE, and DELETE moves to join them. It had been sitting in the pane header beside UP and REFRESH — two things that move the pane and one that destroys a file, in one row — and the three that act on the selected row are now together in a row of their own, under the name of the row they are about. One SFTP rename underneath both new operations and two separate questions on the screen, because the answer is a different kind of thing each time: a rename takes a name and must not take a path, a move takes a directory and must not take a name. One box asking for a destination path would be fewer controls and a worse question — the two mistakes it invites are exactly the ones the split makes impossible. Each form opens on the answer that changes nothing, so what gets edited is the half somebody came to change. Three refusals are made here, and they are the ones a server's message would not explain: a separator in a name (no such file, about a directory nobody meant to name), a destination equal to the source (success, and a file that did not move), and a directory into itself (EINVAL, true and useless). Everything else is the server's — a name already taken, a path that is not there, a mount that cannot be crossed — and arrives in the status line in its own words. Neither is painted in the deletion card's red: a rename is undone by renaming back, and spending that red twice makes it stop meaning anything. ◆ THE LISTING REFRESHES ITSELF when a transfer lands in it. Both panes, since an upload lands on the host and a download lands here and either leaves the pane that is showing that directory a step behind. Only the directory actually on screen: a queue moving a hundred files elsewhere must not be a round trip per file for a listing nobody is looking at. It waits rather than interrupting. Another operation running would have two listings racing for one collection, and re-listing under an open question is how the row somebody answered about stops being the row that gets acted on — so the staleness is remembered and settled at the end of the operation or when the question is dismissed. It says nothing either way: nobody asked for it, and the status line at that moment is carrying the news about the transfer. The selection survives, re-found by path, which is what makes an unasked-for refresh safe to run under somebody's pointer at all. ◆ THE LAYOUT SUITE FOUND A REAL FAULT and the fix is where the height goes. The rename form did not fit the 204 pixels the session shell's own sidebar leaves this pane — MKDIR and the listing were pushed off the bottom. The form is shorter now (the placeholder says what the box wants, so there is no field label above it), and the item row and the breadcrumb step aside while any of the three questions is up, which is what the phone head already did with its own buttons. Measuring also settled something the markup had been claiming and is not true: at that width the listing collapses to nothing under any of the three cards, the deletion card included, and has done since the sidebar arrived. So "the row it is about is still on screen" is retired from the deletion card's remark, and the full path each card carries is what identifies the row instead. Recorded where it is drawn rather than left to be rediscovered. TESTS. SftpPath.Resolve gets the four shapes, ~ with and without a home to expand it to, a blank box, and climbing past the root. ShellFlowTests gets nine against a session that records what it was asked to do — which path was renamed, which directories were listed — rather than against the sentence this code writes about itself: the path box absolute and relative, rename in place, the separator refusal, a move by relative path, the no-op refusal, the three questions displacing each other, and the three rules the automatic refresh follows. The local half is in TransferQueueingTests, where a real folder answers. Two new shapes are measured, and the deletion card's own measurement moves to the connected state — the only state it can be asked in, and the narrow one. What the tests cannot reach is in docs/manual-checks.md as phase 2b: a real server's cross-device and name-taken refusals, and a soft keyboard, which is why the phone's path box has a GO button rather than trusting whatever the IME decided to draw on its return key. |
||
|
|
ef12e8cc99 |
Stop the data plane's tests racing the socket they just connected
CI went red on the run that added the renderer tests, and not on anything they assert: TerminalDataPlaneTests.Output_ReachesTheRenderer read the output frame where it expected the session's opening one, having lost a race that has been in the helper since it was written. Connected and attached are two different moments. ClientWebSocket.ConnectAsync completes on the 101, which UpgradeAsync writes before it has a WebSocket to attach — it builds one from the stream and swaps it in a few instructions later, on the accept thread. SendAsync drops anything sent in between, which is the transport's documented contract rather than a bug: there is nowhere to put a frame for a renderer that is not there, and queueing it is the unbounded growth the credit window exists to prevent. So a helper that returned on the handshake and let its caller send immediately was betting on thread scheduling, every run, on every test in the file. It only started losing now because that assembly grew nine tests and a JavaScript engine to run them in, which is more work in parallel with a window measured in instructions. The race is older than the branch that exposed it. ConnectAsync now waits for the plane's own SocketAttached, subscribed before the connection because the event can be over before ConnectAsync returns, and bounded so a socket that never attaches fails the helper instead of hanging the suite in a later receive. TerminalWorkspaceTests.ConnectRendererAsync has the same exposure through OpenSessionAsync's opening frame and now waits on WaitForRendererAsync, with a note on why that is enough for the reattach tests and what would stop being enough. ◆ NOTHING IN src CHANGED, AND THAT IS THE CONCLUSION RATHER THAN THE SHORTCUT. Production waits for exactly this moment already — every path that opens a session goes through TerminalWorkspace.WaitForRendererAsync, which resolves from the same few lines that raise the event this helper now waits on. Only the tests skipped the gate the application does not. Verified by widening the window rather than by hunting the flake: a 100ms delay inserted between the 101 and the attach, in a throwaway tree, hangs the old helper outright — both frames dropped, the test blocked in receive — and passes 89/89 with this one. Green three times over on the CI platform besides (Alpine, musl, Release, dotnet/sdk:10.0-alpine). |
||
|
|
963cb7f670 |
Reconnect the terminal view when somebody comes back to it
Going back to a terminal left alone for a while found it stuck on "Reconnecting the terminal view…", and stuck is the right word: the banner stayed and nothing behind it was reconnecting. The page's whole recovery story was a setTimeout chain, and a chain is exactly what a WebView is entitled to stop running. Chromium throttles timers in a page nobody is looking at — down to once a minute once it has been hidden five minutes — and a renderer that is frozen, or reclaimed and not yet reloaded, runs none of them. So the socket drops while nobody is watching, the banner goes up, the retry is scheduled, and the retry is then the one thing not running. Three defects, each of which leaves that banner up for the rest of the page's life. ◆ NOTHING LISTENED FOR THE PAGE COMING BACK. The only thing that could clear the banner was a timer that may never fire. terminal.js now reconnects on visibilitychange, focus and online — the events that mean somebody is looking again, and the ones that cannot be throttled — cancelling the pending timer and resetting the backoff. Over a healthy socket all three do nothing, which is what makes them safe to fire as often as clicking a window does. ◆ A HANDSHAKE THAT NEVER FINISHED WAS INVISIBLE. Every retry was scheduled by a close or an error, so an attempt parked in CONNECTING — which is what a suspended renderer leaves behind — scheduled nothing at all, ever. There is now a five-second watchdog on the handshake. ◆ STALE SOCKETS SCHEDULED RETRIES, AND THAT ONE IS A LOOP RATHER THAN A STALL. connect() never detached the old socket's handlers, and the host aborts the displaced socket on takeover — TerminalDataPlane.UpgradeAsync, doing exactly what it should. That close read as a fresh failure and scheduled a retry against the socket that had just succeeded, whose own close scheduled the next: no fixed point, reconnecting every second forever with the banner up for most of it. Every handler now asks whether it is still the page's own attempt, and connect() closes what it abandons. ◆ WHICH OF THE PLATFORM BEHAVIOURS ACTUALLY BIT IS NOT ESTABLISHED, and the fix does not depend on knowing. Throttled timers, a frozen renderer and a reclaimed one all end at the same dead timer; guessing between them would have produced a narrower fix for one of the three. THE TEST RUNS terminal.js ITSELF, in a fake browser, inside dotnet test. RendererPage loads the file the shell project ships — not a transcription of its logic into C#, which would be a copy that stays correct while the page rots — into a Jint engine, one per test, over a harness that fakes a WebSocket and a clock and nothing else. Jint rather than a node script because CI would run the node one and nobody's inner loop would; the cost is that Jint is not Chromium, so this proves the page's logic and nothing about how a WebView behaves. That line is drawn in RendererPage's remark and picked up by two new manual checks, 1.10 for the desktop and 11.12a for the phone, which own the platform half. Four of the nine tests fail against the page as it stood — the stale close, the parked handshake, and the two wake-ups. Two more assert that a wake-up over a healthy socket does nothing, and pass against either version on purpose: they are what stops the cure being worse. Left alone deliberately: a socket that is open and dead shows no banner at all, because readyState still reads OPEN. That looks like a terminal that swallows what is typed, needs a liveness probe rather than a faster retry, and is written down at the end of 11.12a rather than quietly bundled in here. |
||
|
|
68964d8a34 |
Merge pull request 'Name a quick-connect result's vault, and search on it' (#13) from claude/search-items-vault-display-865eb0 into main
Reviewed-on: #13 |
||
|
|
e252d337d1 |
Take the SSH.NET release that fixes the SCP path traversal
Every project in the repository stopped building, on a warning about a package
none of them had changed:
error NU1903: Warning As Error: Package 'SSH.NET' 2025.1.0 has a known high
severity vulnerability
GHSA-q939-rpr3-3284 was published after the pin was written. ScpClient.Download
in recursive mode takes the filenames the server sends and joins them to the
local destination without validating them, so a malicious or compromised SCP
server can name "../" its way out of the download directory and write anywhere
the client process can — CWE-22, 7.1, and the same shape as OpenSSH's
CVE-2019-6111 with the traversal left in. Everything up to and including
2025.1.0 is affected; 2026.0.0 is the fix.
◆ THIS APPLICATION WAS NEVER EXPOSED, AND THE UPGRADE IS STILL THE RIGHT ANSWER.
Nothing here constructs a ScpClient. File transfer is SftpClient throughout —
SshNetSftpSession is the only path to a remote file — so the vulnerable method
has no caller to reach it from. That makes this a hygiene bump rather than an
incident, and it is worth saying plainly because the alternative on offer was a
scoped NuGetAuditSuppress with that reasoning written beside it. A suppression is
what you reach for when there is nothing to upgrade TO. There is: the maintainers
shipped the fix, taking it costs two nullable annotations, and a suppression
would have left this repository carrying a known-vulnerable library and a comment
explaining why that is fine — which stays true only until somebody adds the first
ScpClient call and has no reason to look here.
BouncyCastle.Cryptography moves 2.6.2 -> 2.7.0 as a consequence, not a
preference. CentralPackageTransitivePinningEnabled makes the PackageVersion here
the resolved version for a transitive dependency, and SSH.NET 2026.0.0 asks for
2.7.0, so a pin left at 2.6.2 is a downgrade error rather than a version this
repository gets to choose. Its comment records the constraint so the next person
does not try to walk it back.
◆ THE RELEASE NOTES SAY NO BREAKING CHANGES. THE NULLABLE ANNOTATIONS DISAGREE.
ConnectionInfo.CurrentServerEncryption is now string? — correct of them, since it
really is null until the key exchange completes — and that is CS8601 at both
places this codebase reads a cipher off a live connection. Neither can observe
the null: SshNetConnection and SshNetSftpSession are only ever constructed after
ConnectAsync has been awaited, which is what their existing remarks already say
and why the reads are at construction rather than lazy.
Coalesced to string.Empty rather than silenced with !, because empty is already
this codebase's word for a cipher that could not be read. MainWindowViewModel's
NullIfEmpty exists for exactly that, TerminalWorkspace.GetSessionFacts already
hands over string.Empty on the same grounds, and the status bar collapses on it.
So the boundary keeps the interface's non-nullable string, no consumer changes,
and an unreachable null would degrade the way a real one already does.
SshNetConnection's remark asserted that SSH.NET types the property non-nullable
and that reading straight through was therefore honest rather than lazy. That
sentence is now false, and a comment contradicting the line beneath it is worse
than no comment, so it says what is actually true: the coalesce is there for the
annotation and the pre-key-exchange window this call site cannot be in.
The Android head needed its own restore. It is outside DodoSSH.slnx, so the
solution-wide --force-evaluate does not reach it, and left alone it would have
kept the vulnerable pin and gone stale under locked mode — the hazard
platform-flags.md records, arriving on schedule.
Heeding the warning left by the ILLink commit: --force-evaluate on Windows
rewrote all 39 lock files with CRLF, and 21 of them had no content change. Only
the 18 that really moved are here.
Verified with `dotnet build` at the root, no audit-disabling flags: 0 errors,
NU1903 gone, and the 21 remaining warnings are the pre-existing Meziantou/CA and
Avalonia ones that were present in the failing build too.
A library upgrade is not a version string, so the suites that exercise it were
run against live containers rather than trusted: 87 Ssh, 80 Terminal, 27
ObjectStore, 10 Transfer, none failing.
|
||
|
|
fdea3911c1 |
Name a quick-connect result's vault, and search on it
Ctrl+K reaches every vault the session holds a key for — that is deliberate, and it is what makes the palette worth opening from anywhere. What it did not do was say where a result came from. A team and a person who both call a machine prod-db got two identical rows, and Enter took whichever the ranking happened to put first: the same three characters, two different machines, depending on nothing anybody could see. Nothing new is computed for this. HostRowViewModel.VaultBadge has been filled in since the host list learned to span vaults, and it is already drawn on the hosts board, the keychain, the snippets list and the known-hosts pane. The palette was the one list reaching across every vault that did not print it. ◆ THE VAULT IS THE LAST OF THE FOUR RANKS, AND THAT IS THE POINT. A vault name is the widest reading of the three the palette had: one word matches every host in that vault at once, where a name or an address matches one machine. So it sits behind name-starts-with, name-contains and address — otherwise typing a machine's name would bury it under everybody else's. It matches on HasVaultBadge rather than on VaultName, so the search only ever matches what the row actually shows. A session holding one vault prints no vault on any row, and matching it there would answer "personal" with the entire keychain, ranked behind nothing and explained by nothing on screen. In the row, the right-hand column becomes two lines against the two on the left, in the same order: what this is above, how it is reached below. With one vault the badge is empty, the line collapses, and the kind word stays centred exactly where it was. The name is capped and ellipsised because that column is Auto-sized — a long vault name would otherwise take its width out of the host name beside it. Three tests, one per claim. VaultSharingTests puts platform-gateway in the personal vault and prod-db in "Platform secrets" and types "platform": both come back, the gateway first. ShellFlowTests types the personal vault's own name into a one-vault session and gets nothing. QuickConnectTests is the markup's half — the shared vault's row draws PLATFORM SECRETS, and a one-vault row draws no vault at all — with the shared vault put into the session through the layout suite's own StubTeamServer, as the settings pages' suite does it. 449 App tests and 155 layout tests pass. Both suites were run with -p:NuGetAudit=false: SSH.NET 2025.1.0 has picked up GHSA-q939-rpr3-3284 and NU1903 fails restore repo-wide, which predates this branch and is nothing to do with it. No package or lock file is touched here. |
||
|
|
10f80bded1 |
Merge pull request 'Colour the window's frame, inset Hosts like its neighbours, drop Pins' (#9) from claude/title-bar-color-windows-6d386c into main
Reviewed-on: #9 |