Merge branch 'main' into claude/m3-implementation-57f9d7
ci / build and test (push) Failing after 2s

Three files conflicted, and two of the resolutions are more than a choice of
side.

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

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

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

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

Two comments the auto-merge left describing a world with one vault in it: the
SCOPES rail's, which said team vaults are refused by the access service, and
the host sidebar's "One heading, for one vault".
This commit is contained in:
2026-07-31 12:26:59 +02:00
42 changed files with 3708 additions and 155 deletions
+130 -7
View File
@@ -41,32 +41,49 @@ internal sealed class RefreshingAccessTokenProvider(
private readonly SemaphoreSlim gate = new(1, 1);
private TokenSet tokens = initial;
/// <summary>
/// The refresh token this provider currently holds, or null when none was granted.
/// </summary>
/// <remarks>
/// Read rather than raised as an event, because the one caller — the shell, persisting it so a later
/// launch can resume — has a moment of its own to do that in and no interest in the instant a
/// rotation happens. A volatile read of a reference the refresh path replaces wholesale: the value is
/// either the old set or the new one, never a half-written one.
/// </remarks>
internal string? RefreshToken => Volatile.Read(ref tokens).RefreshToken;
public async ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken)
{
if (!tokens.NeedsRefresh(clock))
var current = Volatile.Read(ref tokens);
if (!current.NeedsRefresh(clock))
{
return tokens.AccessToken;
return current.AccessToken;
}
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!tokens.NeedsRefresh(clock))
current = Volatile.Read(ref tokens);
if (!current.NeedsRefresh(clock))
{
return tokens.AccessToken;
return current.AccessToken;
}
if (tokens.RefreshToken is null)
if (current.RefreshToken is null)
{
throw new InvalidOperationException(
"The access token has expired and no refresh token was granted. Sign in again.");
}
tokens = await oidc.RefreshAsync(tokens.RefreshToken, cancellationToken)
var refreshed = await oidc.RefreshAsync(current.RefreshToken, cancellationToken)
.ConfigureAwait(false);
return tokens.AccessToken;
Volatile.Write(ref tokens, refreshed);
return refreshed.AccessToken;
}
finally
{
@@ -77,6 +94,26 @@ internal sealed class RefreshingAccessTokenProvider(
public void Dispose() => gate.Dispose();
}
/// <summary>
/// Refuses to open anything, for the flows that must never reach a browser.
/// </summary>
/// <remarks>
/// <see cref="ServerConnection.ResumeAsync"/> uses only the refresh grant, which needs no user agent —
/// but <see cref="OidcClient"/> takes a launcher in its constructor because its other two flows do. This
/// makes "a resume never opens a browser" a property of the object rather than of the code path, so a
/// future call that wandered into an interactive flow would fail loudly here instead of surprising
/// somebody with a sign-in page that opened by itself.
/// </remarks>
internal sealed class NoBrowserLauncher : IBrowserLauncher
{
internal static NoBrowserLauncher Instance { get; } = new();
public Task OpenAsync(Uri url, CancellationToken cancellationToken) =>
throw new InvalidOperationException(
"This connection was resumed from a remembered sign-in and must not open a browser. "
+ "Signing in interactively is something the user asks for.");
}
/// <summary>
/// What a signed-in server offers, as everything above the session layer needs it.
/// </summary>
@@ -118,6 +155,18 @@ public interface IVaultServer : IDisposable
/// <summary>Sync tuning derived from what this server actually accepts.</summary>
SyncOptions SyncOptions { get; }
/// <summary>
/// The refresh token this connection holds right now, or null when the provider granted none.
/// </summary>
/// <remarks>
/// On the interface because remembering it is what lets a later launch come back online without a
/// browser, and the thing doing the remembering — the shell — must not have to know whether it is
/// holding a real connection or a test's stand-in. It changes over the life of a connection: a
/// provider that rotates hands back a new one on every refresh, so a caller that persists this has
/// to re-read it rather than cache it.
/// </remarks>
string? RefreshToken { get; }
}
/// <summary>
@@ -207,6 +256,9 @@ public sealed class ServerConnection : IVaultServer
MaxOperationsPerPush = Math.Clamp(Meta.MaxOperationsPerPush, 1, 500),
};
/// <inheritdoc />
public string? RefreshToken => tokens.RefreshToken;
/// <summary>
/// Discovers the server, signs the user in through their browser, and returns the connection.
/// </summary>
@@ -257,6 +309,77 @@ public sealed class ServerConnection : IVaultServer
}
}
/// <summary>
/// Re-establishes a connection from a remembered refresh token, with no browser and no user present.
/// </summary>
/// <remarks>
/// <para>
/// The difference between an application that is signed in and one that merely was. Without this, a
/// machine that has been set up is offline from launch until somebody goes and presses a button —
/// which means the sync loop, the outbox and a colleague's changes all wait on an action nobody has a
/// reason to take.
/// </para>
/// <para>
/// Discovery runs again rather than being cached, because the client is deliberately configured by the
/// server: the authority, the client id and the scopes are read from
/// <c>/.well-known/dodossh-configuration</c> at every connection, so a deployment that moves its
/// identity provider does not leave every client pinned to the old one.
/// </para>
/// <para>
/// It fails rather than falling back when the token has been revoked or has expired, and that is the
/// point of passing a launcher that refuses: a resume must never quietly become an interactive
/// sign-in, which from a user's side is a browser window that opens on its own. The caller's answer to
/// a failure is to stay offline and forget the token.
/// </para>
/// </remarks>
/// <param name="serverUrl">The server this profile is enrolled against.</param>
/// <param name="refreshToken">The remembered token.</param>
/// <param name="clock">Time source, for token expiry.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task<ServerConnection> ResumeAsync(
Uri serverUrl,
string refreshToken,
TimeProvider clock,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(serverUrl);
ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
ArgumentNullException.ThrowIfNull(clock);
var transport = new HttpClient { BaseAddress = serverUrl };
try
{
var discovery = new DodoSshApiClient(transport, UnavailableAccessTokenProvider.Instance);
var configuration = await discovery.GetConfigurationAsync(cancellationToken)
.ConfigureAwait(false);
var meta = await discovery.GetMetaAsync(cancellationToken).ConfigureAwait(false);
var oidc = new OidcClient(
transport, NoBrowserLauncher.Instance, clock, BuildOidcOptions(configuration));
var tokenSet = await oidc.RefreshAsync(refreshToken, cancellationToken).ConfigureAwait(false);
var refreshing = new RefreshingAccessTokenProvider(oidc, tokenSet, clock);
return new ServerConnection(
serverUrl,
transport,
configuration,
meta,
oidc,
refreshing,
new DodoSshApiClient(transport, refreshing));
}
catch
{
transport.Dispose();
throw;
}
}
/// <inheritdoc />
public void Dispose()
{
@@ -96,6 +96,7 @@ public sealed partial class VaultSession : IAsyncDisposable
Conflicts = new ConflictStore(caches, protector, clock);
Vault = new VaultStore(caches, clock);
Unlock = new UnlockStore(caches, clock);
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
Hosts = new HostRepository(Items, Outbox, keyring);
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
Credentials = new CredentialRepository(Items, Outbox, keyring);
@@ -169,6 +170,51 @@ public sealed partial class VaultSession : IAsyncDisposable
/// </remarks>
internal UnlockStore Unlock { get; }
/// <remarks>
/// Only reachable from an open session, which is the point rather than an accident of where it was
/// put: the token is sealed under this session's cache key, so a locked machine cannot read it and
/// therefore cannot reach the server at all. See <c>RememberedSignInStore</c>.
/// </remarks>
internal RememberedSignInStore SignIn { get; }
/// <summary>
/// Remembers the sign-in this machine currently holds, so a later launch can resume it.
/// </summary>
/// <param name="refreshToken">
/// The refresh token the connection holds <em>now</em>. Providers rotate these, so a caller that
/// notices a change has to call this again — the value is not a constant for the life of a sign-in.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task RememberSignInAsync(string refreshToken, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
return SignIn.SaveAsync(refreshToken, cancellationToken);
}
/// <summary>
/// Reads the sign-in this machine may resume, or null when there is none to resume.
/// </summary>
/// <remarks>
/// Null covers three situations that are one situation from the caller's side — nothing was ever
/// remembered, the record was written under a different identity, or its tag no longer verifies — and
/// the answer to all three is the same: sign in through the browser.
/// </remarks>
public Task<string?> ReadRememberedSignInAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
return SignIn.ReadAsync(cancellationToken);
}
/// <summary>Forgets the remembered sign-in.</summary>
public Task ForgetSignInAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
return SignIn.ForgetAsync(cancellationToken);
}
/// <summary>Runs one synchronisation pass over one vault.</summary>
/// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
/// <param name="vaultId">The vault to sync.</param>