diff --git a/BuildMonitor.Test/TokenStorageTests.cs b/BuildMonitor.Test/TokenStorageTests.cs
new file mode 100644
index 0000000..1bf342a
--- /dev/null
+++ b/BuildMonitor.Test/TokenStorageTests.cs
@@ -0,0 +1,357 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.BuildMonitor.Test;
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using ktsu.CredentialCache;
+using ktsu.CredentialCache.Storage;
+using ktsu.RoundTripStringJsonConverter;
+using ktsu.Semantics.Strings;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using CredentialCache = ktsu.CredentialCache.CredentialCache;
+
+///
+/// Covers where provider and owner access tokens live. They are GitHub and Azure DevOps personal
+/// access tokens, so the contract under test is that they reach the OS secret store and do not
+/// survive in the app data file earlier versions serialized them into.
+///
+[TestClass]
+public sealed class TokenStorageTests
+{
+ private CredentialCache Cache { get; set; } = null!;
+
+ [TestInitialize]
+ public void SetUp()
+ {
+ Cache = new CredentialCache(new InMemoryCredentialStore());
+ TokenStorage.UseCache(Cache);
+ }
+
+ [TestCleanup]
+ public void TearDown()
+ {
+ TokenStorage.UseCache(null);
+ Cache.Dispose();
+ }
+
+ ///
+ /// A provider with no network behaviour, so the storage contract can be driven without a real
+ /// GitHub or Azure DevOps client.
+ ///
+ private sealed class TestProvider(string name) : BuildProvider
+ {
+ internal override BuildProviderName Name { get; } = name.As();
+
+ ///
+ /// Writes the provider token the same way the base class's setter does. The setter itself is
+ /// private, so this reaches the storage directly rather than widening it for a test.
+ ///
+ internal void SetToken(string token) =>
+ Assert.IsTrue(TokenStorage.Write(TokenPersona, token.As()));
+
+ internal BuildProviderToken ReadToken() => Token;
+
+ internal override Task UpdateRepositoriesAsync(Owner owner) => Task.CompletedTask;
+ internal override Task UpdateBuildsAsync(Repository repository) => Task.CompletedTask;
+ internal override Task UpdateBuildAsync(Build build) => Task.CompletedTask;
+ internal override Task UpdateRunAsync(Run run) => Task.CompletedTask;
+ }
+
+ ///
+ /// A store standing in for a machine whose native secret library will not load — a Linux host
+ /// with no Secret Service provider, which is what a container or an SSH session usually is.
+ ///
+ private sealed class UnavailableCredentialStore : ICredentialStore
+ {
+ public string Name => "Unavailable";
+
+ public bool TryLoad(PersonaGUID persona, out Credential? credential) =>
+ throw new DllNotFoundException("libsecret-1.so.0");
+
+ public void Save(PersonaGUID persona, Credential credential) =>
+ throw new DllNotFoundException("libsecret-1.so.0");
+
+ public bool Remove(PersonaGUID persona) => throw new DllNotFoundException("libsecret-1.so.0");
+ }
+
+ ///
+ /// Mirrors how writes the app data file: references preserved,
+ /// and semantic strings round-tripped as plain strings rather than as char arrays.
+ ///
+ private static readonly JsonSerializerOptions AppDataLike = BuildAppDataLikeOptions();
+
+ private static JsonSerializerOptions BuildAppDataLikeOptions()
+ {
+ JsonSerializerOptions options = new()
+ {
+ ReferenceHandler = ReferenceHandler.Preserve,
+ };
+ options.Converters.Add(new RoundTripStringJsonConverterFactory());
+ return options;
+ }
+
+ private static TestProvider NewProvider(string name = "TestHub") => new(name);
+
+ private static Owner AddOwner(TestProvider provider, string name)
+ {
+ OwnerName ownerName = name.As();
+ Owner owner = provider.CreateOwner(ownerName);
+ Assert.IsTrue(provider.Owners.TryAdd(ownerName, owner));
+ return owner;
+ }
+
+ ///
+ /// Two providers never share a persona, so setting one's token cannot disturb the other's.
+ ///
+ [TestMethod]
+ public void ProviderPersonasAreDistinct()
+ {
+ PersonaGUID gitHub = TokenStorage.ProviderPersona("GitHub".As());
+ PersonaGUID azure = TokenStorage.ProviderPersona("AzureDevOps".As());
+
+ Assert.AreNotEqual(gitHub, azure);
+ }
+
+ ///
+ /// The same organization name can exist on more than one provider, so the owner persona is scoped
+ /// by provider too.
+ ///
+ [TestMethod]
+ public void OwnerPersonasAreScopedByProvider()
+ {
+ OwnerName owner = "ktsu-dev".As();
+ PersonaGUID onGitHub = TokenStorage.OwnerPersona("GitHub".As(), owner);
+ PersonaGUID onAzure = TokenStorage.OwnerPersona("AzureDevOps".As(), owner);
+
+ Assert.AreNotEqual(onGitHub, onAzure);
+ }
+
+ ///
+ /// An owner's persona is never the provider's, so an owner override cannot overwrite the
+ /// provider-level token.
+ ///
+ [TestMethod]
+ public void OwnerPersonaIsNotTheProviderPersona()
+ {
+ BuildProviderName provider = "GitHub".As();
+
+ Assert.AreNotEqual(
+ TokenStorage.ProviderPersona(provider),
+ TokenStorage.OwnerPersona(provider, "ktsu-dev".As()));
+ }
+
+ ///
+ /// Persona derivation is deterministic, and pinned. Changing it would orphan every token already
+ /// in the store, so it must not drift silently.
+ ///
+ [TestMethod]
+ public void PersonaDerivationIsStable()
+ {
+ BuildProviderName provider = "GitHub".As();
+
+ Assert.AreEqual(
+ TokenStorage.ProviderPersona(provider).ToString(),
+ TokenStorage.ProviderPersona(provider).ToString());
+ Assert.IsTrue(Guid.TryParse(TokenStorage.ProviderPersona(provider).ToString(), out _));
+ }
+
+ ///
+ /// A provider token set through the normal path is readable again, and came from the store.
+ ///
+ [TestMethod]
+ public void ProviderTokenRoundTripsThroughTheStore()
+ {
+ TestProvider provider = NewProvider();
+
+ provider.SetToken("ghp_provider");
+
+ Assert.AreEqual("ghp_provider", provider.ReadToken().ToString());
+ Assert.IsTrue(Cache.TryGet(provider.TokenPersona, out Credential? credential));
+ Assert.IsInstanceOfType(credential);
+ }
+
+ ///
+ /// Setting a token leaves nothing behind in the field the app data file is built from. This is
+ /// the whole point of the change.
+ ///
+ [TestMethod]
+ public void SettingAProviderTokenWritesNothingToAppData()
+ {
+ TestProvider provider = NewProvider();
+
+ provider.SetToken("ghp_provider");
+
+ Assert.IsTrue(provider.LegacyToken.IsEmpty());
+ }
+
+ ///
+ /// The serialized form of a provider carries no token. A reader of the app data file learns
+ /// nothing.
+ ///
+ [TestMethod]
+ public void SerializedProviderCarriesNoToken()
+ {
+ TestProvider provider = NewProvider();
+ provider.SetToken("ghp_secret_value");
+
+ string json = JsonSerializer.Serialize(provider, AppDataLike);
+
+ Assert.DoesNotContain("ghp_secret_value", json, StringComparison.Ordinal);
+ Assert.Contains("\"Token\":\"\"", json, StringComparison.Ordinal);
+ }
+
+ ///
+ /// An owner override is stored separately from the provider token, and both survive.
+ ///
+ [TestMethod]
+ public void OwnerTokenIsStoredSeparatelyFromTheProviderToken()
+ {
+ TestProvider provider = NewProvider();
+ Owner owner = AddOwner(provider, "ktsu-dev");
+
+ provider.SetToken("ghp_provider");
+ owner.Token = "ghp_owner".As();
+
+ Assert.AreEqual("ghp_provider", provider.ReadToken().ToString());
+ Assert.AreEqual("ghp_owner", owner.Token.ToString());
+ Assert.IsTrue(owner.HasToken);
+ Assert.IsTrue(owner.LegacyToken.IsEmpty());
+ }
+
+ ///
+ /// Clearing a token removes it from the store rather than leaving an empty entry behind.
+ ///
+ [TestMethod]
+ public void ClearingATokenRemovesItFromTheStore()
+ {
+ TestProvider provider = NewProvider();
+ Owner owner = AddOwner(provider, "ktsu-dev");
+ owner.Token = "ghp_owner".As();
+
+ owner.Token = new();
+
+ Assert.IsFalse(owner.HasToken);
+ Assert.IsFalse(Cache.TryGet(owner.TokenPersona, out _));
+ }
+
+ ///
+ /// A provider token and an owner token left by an earlier version are both moved into the store.
+ ///
+ [TestMethod]
+ public void MigrationMovesProviderAndOwnerTokens()
+ {
+ TestProvider provider = NewProvider();
+ Owner owner = AddOwner(provider, "ktsu-dev");
+ provider.LegacyToken = "ghp_old_provider".As();
+ owner.LegacyToken = "ghp_old_owner".As();
+
+ int migrated = TokenStorage.MigrateLegacyTokens([provider]);
+
+ Assert.AreEqual(2, migrated);
+ Assert.AreEqual("ghp_old_provider", provider.ReadToken().ToString());
+ Assert.AreEqual("ghp_old_owner", owner.Token.ToString());
+ }
+
+ ///
+ /// Migration also blanks the old copies. Ceasing to write a secret does not remove the one
+ /// already on disk, so this is the half that does the security work.
+ ///
+ [TestMethod]
+ public void MigrationBlanksThePlaintextCopies()
+ {
+ TestProvider provider = NewProvider();
+ Owner owner = AddOwner(provider, "ktsu-dev");
+ provider.LegacyToken = "ghp_old_provider".As();
+ owner.LegacyToken = "ghp_old_owner".As();
+
+ _ = TokenStorage.MigrateLegacyTokens([provider]);
+
+ Assert.IsTrue(provider.LegacyToken.IsEmpty());
+ Assert.IsTrue(owner.LegacyToken.IsEmpty());
+ }
+
+ ///
+ /// With nothing left over there is nothing to migrate, so a second start is a no-op.
+ ///
+ [TestMethod]
+ public void MigrationIsIdempotent()
+ {
+ TestProvider provider = NewProvider();
+ provider.LegacyToken = "ghp_old_provider".As();
+
+ _ = TokenStorage.MigrateLegacyTokens([provider]);
+
+ Assert.AreEqual(0, TokenStorage.MigrateLegacyTokens([provider]));
+ Assert.AreEqual("ghp_old_provider", provider.ReadToken().ToString());
+ }
+
+ ///
+ /// A stale token in the old file must not overwrite the one currently in the store — but it is
+ /// still cleared.
+ ///
+ [TestMethod]
+ public void MigrationKeepsTheStoredTokenAndStillClearsTheStaleOne()
+ {
+ TestProvider provider = NewProvider();
+ provider.SetToken("ghp_current");
+ provider.LegacyToken = "ghp_stale".As();
+
+ Assert.AreEqual(1, TokenStorage.MigrateLegacyTokens([provider]));
+ Assert.AreEqual("ghp_current", provider.ReadToken().ToString());
+ Assert.IsTrue(provider.LegacyToken.IsEmpty());
+ }
+
+ ///
+ /// If the secret store will not take the token, the old copy stays where it is. Clearing it would
+ /// destroy the only copy the user has.
+ ///
+ [TestMethod]
+ public void MigrationKeepsTheLegacyTokenWhenTheStoreRefuses()
+ {
+ using CredentialCache unavailable = new(new UnavailableCredentialStore());
+ TokenStorage.UseCache(unavailable);
+
+ TestProvider provider = NewProvider();
+ provider.LegacyToken = "ghp_old_provider".As();
+
+ Assert.AreEqual(0, TokenStorage.MigrateLegacyTokens([provider]));
+ Assert.AreEqual("ghp_old_provider", provider.LegacyToken.ToString());
+ }
+
+ ///
+ /// A machine with no secret store reads as "no token" rather than throwing. BuildMonitor is a
+ /// desktop application, and an exception out of a token read would take down the render loop; it
+ /// reports the problem in the log instead. What it must never do is fall back to a plain file.
+ ///
+ [TestMethod]
+ public void ReadingWithoutASecretStoreIsEmptyRatherThanFatal()
+ {
+ using CredentialCache unavailable = new(new UnavailableCredentialStore());
+ TokenStorage.UseCache(unavailable);
+
+ TestProvider provider = NewProvider();
+
+ Assert.IsTrue(provider.ReadToken().IsEmpty());
+ Assert.IsFalse(TokenStorage.Write(provider.TokenPersona, "ghp_x".As()));
+ }
+
+ ///
+ /// Two owners under one provider do not share a token.
+ ///
+ [TestMethod]
+ public void OwnersDoNotShareTokens()
+ {
+ TestProvider provider = NewProvider();
+ Owner first = AddOwner(provider, "ktsu-dev");
+ Owner second = AddOwner(provider, "ktsu-io");
+
+ first.Token = "ghp_first".As();
+
+ Assert.AreEqual("ghp_first", first.Token.ToString());
+ Assert.IsFalse(second.HasToken);
+ }
+}
diff --git a/BuildMonitor/BuildMonitor.cs b/BuildMonitor/BuildMonitor.cs
index 3672999..04eb97b 100644
--- a/BuildMonitor/BuildMonitor.cs
+++ b/BuildMonitor/BuildMonitor.cs
@@ -66,6 +66,13 @@ private static void OnStart()
// add more providers here as needed
+ int migratedTokens = TokenStorage.MigrateLegacyTokens(AppData.BuildProviders.Values);
+ if (migratedTokens > 0)
+ {
+ Log.Info($"Moved {migratedTokens} access token(s) out of the app data file into the OS secret store");
+ needsSave = true;
+ }
+
if (needsSave)
{
QueueSaveAppData();
diff --git a/BuildMonitor/BuildMonitor.csproj b/BuildMonitor/BuildMonitor.csproj
index d4631a9..b83ec39 100644
--- a/BuildMonitor/BuildMonitor.csproj
+++ b/BuildMonitor/BuildMonitor.csproj
@@ -12,6 +12,7 @@
+
diff --git a/BuildMonitor/BuildProvider.cs b/BuildMonitor/BuildProvider.cs
index 83bd695..b922fdc 100644
--- a/BuildMonitor/BuildProvider.cs
+++ b/BuildMonitor/BuildProvider.cs
@@ -7,6 +7,7 @@ namespace ktsu.BuildMonitor;
using Hexa.NET.ImGui;
+using ktsu.CredentialCache;
using ktsu.ImGui.Popups;
using ktsu.Semantics.Strings;
@@ -46,8 +47,35 @@ internal abstract class BuildProvider
internal abstract BuildProviderName Name { get; }
[JsonInclude]
internal BuildProviderAccountId AccountId { get; private set; } = new();
+
+ ///
+ /// The provider token as earlier versions persisted it: plaintext, in the app data file.
+ ///
+ ///
+ /// Retained under its original JSON name only so
+ /// can move an existing token into the OS secret store and blank it here. Nothing writes a token
+ /// here any more.
+ ///
[JsonInclude]
- protected BuildProviderToken Token { get; private set; } = new();
+ [JsonPropertyName("Token")]
+ internal BuildProviderToken LegacyToken { get; set; } = new();
+
+ ///
+ /// The persona this provider's token is stored under.
+ ///
+ [JsonIgnore]
+ internal PersonaGUID TokenPersona => TokenStorage.ProviderPersona(Name);
+
+ ///
+ /// The provider-level access token, held in the OS secret store rather than the app data file.
+ ///
+ [JsonIgnore]
+ protected BuildProviderToken Token
+ {
+ get => TokenStorage.Read(TokenPersona);
+ private set => _ = TokenStorage.Write(TokenPersona, value);
+ }
+
[JsonInclude]
internal ConcurrentDictionary Owners { get; init; } = [];
private bool ShouldShowAccountIdPopup { get; set; }
diff --git a/BuildMonitor/Owner.cs b/BuildMonitor/Owner.cs
index 01dee0b..f821598 100644
--- a/BuildMonitor/Owner.cs
+++ b/BuildMonitor/Owner.cs
@@ -5,6 +5,7 @@ namespace ktsu.BuildMonitor;
using System.Collections.Concurrent;
using System.Text.Json.Serialization;
+using ktsu.CredentialCache;
using ktsu.Semantics.Strings;
internal sealed record class OwnerName : SemanticString { }
@@ -18,12 +19,37 @@ internal sealed class Owner
public bool Enabled { get; set; }
public ConcurrentDictionary Repositories { get; init; } = [];
+ ///
+ /// The owner token as earlier versions persisted it: plaintext, in the app data file.
+ ///
+ ///
+ /// Retained under its original JSON name only so
+ /// can move an existing token into the OS secret
+ /// store and blank it here. Nothing writes a token here any more.
+ ///
+ [JsonInclude]
+ [JsonPropertyName("Token")]
+ internal BuildProviderToken LegacyToken { get; set; } = new();
+
+ ///
+ /// The persona this owner's token is stored under.
+ ///
+ [JsonIgnore]
+ internal PersonaGUID TokenPersona => TokenStorage.OwnerPersona(BuildProvider.Name, Name);
+
///
/// Optional token for this specific owner. If set, overrides the provider-level token.
/// Useful for accessing private repositories in different organizations.
///
- [JsonInclude]
- public BuildProviderToken Token { get; internal set; } = new();
+ ///
+ /// Kept in the OS secret store, not in the app data file.
+ ///
+ [JsonIgnore]
+ public BuildProviderToken Token
+ {
+ get => TokenStorage.Read(TokenPersona);
+ internal set => _ = TokenStorage.Write(TokenPersona, value);
+ }
///
/// Returns true if this owner has a specific token configured.
diff --git a/BuildMonitor/TokenStorage.cs b/BuildMonitor/TokenStorage.cs
new file mode 100644
index 0000000..f38363b
--- /dev/null
+++ b/BuildMonitor/TokenStorage.cs
@@ -0,0 +1,212 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.BuildMonitor;
+
+using System.Security.Cryptography;
+using System.Text;
+
+using ktsu.CredentialCache;
+using ktsu.CredentialCache.Storage;
+using ktsu.Semantics.Strings;
+
+using CredentialCache = ktsu.CredentialCache.CredentialCache;
+
+///
+/// Holds provider and owner access tokens in the operating system's secret store — Windows
+/// Credential Manager, macOS Keychain, or libsecret (Secret Service) on Linux — rather than in
+/// 's JSON file.
+///
+///
+/// The tokens are GitHub and Azure DevOps personal access tokens, usually scoped to
+/// repo/workflow. serializes the whole app data
+/// object to an unencrypted file on every save, which is not somewhere a PAT belongs.
+///
+internal static class TokenStorage
+{
+ ///
+ /// Scopes BuildMonitor's entries within the OS secret store so they cannot collide with another
+ /// ktsu tool's credentials on a shared host.
+ ///
+ internal const string CredentialServiceName = "ktsu.BuildMonitor";
+
+ ///
+ /// Prefixed to every persona seed. Versioned because changing the derivation would orphan every
+ /// token already in the store, so a future change has to be a deliberate, visible one.
+ ///
+ private const string PersonaNamespace = "ktsu.BuildMonitor/v1/";
+
+ private const string UnavailableMessage =
+ "No usable OS secret store was found, so access tokens cannot be read or saved. On Linux this " +
+ "usually means no Secret Service provider (libsecret with GNOME Keyring or KWallet) is installed " +
+ "and unlocked. BuildMonitor will not fall back to writing tokens to a plain file.";
+
+ private static readonly Lazy LazyDefaultCache =
+ new(() => new CredentialCache(CredentialStoreFactory.CreateDefault(CredentialServiceName)));
+
+ private static int _unavailableReported;
+
+ ///
+ /// The cache substituted by , or for the default.
+ ///
+ private static CredentialCache? InjectedCache { get; set; }
+
+ ///
+ /// Gets the cache backing this storage.
+ ///
+ ///
+ /// Built directly rather than taken from so the store
+ /// carries BuildMonitor's own service name; the singleton can only ever use the library default.
+ ///
+ internal static CredentialCache Cache => InjectedCache ?? LazyDefaultCache.Value;
+
+ ///
+ /// Substitutes the backing cache. Test seam; pass to restore the default.
+ ///
+ internal static void UseCache(CredentialCache? cache)
+ {
+ InjectedCache = cache;
+ _ = Interlocked.Exchange(ref _unavailableReported, 0);
+ }
+
+ ///
+ /// Derives the persona holding a provider-level token.
+ ///
+ internal static PersonaGUID ProviderPersona(BuildProviderName provider) =>
+ DerivePersona($"provider/{provider}");
+
+ ///
+ /// Derives the persona holding an owner's override token. Scoped by provider as well as owner,
+ /// because the same organization name can exist on more than one provider.
+ ///
+ internal static PersonaGUID OwnerPersona(BuildProviderName provider, OwnerName owner) =>
+ DerivePersona($"provider/{provider}/owner/{owner}");
+
+ ///
+ /// Reads the token stored under , or an empty token when there is none.
+ ///
+ internal static BuildProviderToken Read(PersonaGUID persona)
+ {
+ try
+ {
+ return Cache.TryGet(persona, out Credential? credential) && credential is CredentialWithToken token
+ ? token.Token.ToString().As()
+ : new();
+ }
+ catch (Exception ex) when (IsUnavailable(ex))
+ {
+ ReportUnavailable(ex);
+ return new();
+ }
+ }
+
+ ///
+ /// Stores under , removing the entry when the
+ /// token is empty.
+ ///
+ /// when the secret store refused the write.
+ internal static bool Write(PersonaGUID persona, BuildProviderToken token)
+ {
+ try
+ {
+ if (token.IsEmpty())
+ {
+ _ = Cache.Remove(persona);
+ return true;
+ }
+
+ Cache.AddOrReplace(persona, new CredentialWithToken
+ {
+ Token = SemanticString.Create(token.ToString()),
+ });
+ return true;
+ }
+ catch (Exception ex) when (IsUnavailable(ex))
+ {
+ ReportUnavailable(ex);
+ return false;
+ }
+ }
+
+ ///
+ /// Moves tokens left in by earlier versions into the secret store and
+ /// blanks them where they were.
+ ///
+ /// How many tokens were moved.
+ internal static int MigrateLegacyTokens(IEnumerable providers)
+ {
+ Ensure.NotNull(providers);
+
+ int migrated = 0;
+
+ foreach (BuildProvider provider in providers)
+ {
+ if (MigrateToken(provider.TokenPersona, provider.LegacyToken, () => provider.LegacyToken = new()))
+ {
+ migrated++;
+ }
+
+ foreach (Owner owner in provider.Owners.Values)
+ {
+ PersonaGUID persona = OwnerPersona(provider.Name, owner.Name);
+ if (MigrateToken(persona, owner.LegacyToken, () => owner.LegacyToken = new()))
+ {
+ migrated++;
+ }
+ }
+ }
+
+ return migrated;
+ }
+
+ ///
+ /// Moves one token, leaving the old copy in place if the secret store will not take it.
+ ///
+ ///
+ /// A token already in the store wins over a legacy one, so a stale copy in the old file cannot
+ /// overwrite a current credential — but the stale copy is still cleared, because ceasing to write
+ /// a secret does not remove the one already on disk.
+ ///
+ private static bool MigrateToken(PersonaGUID persona, BuildProviderToken legacy, Action clearLegacy)
+ {
+ if (legacy.IsEmpty())
+ {
+ return false;
+ }
+
+ if (Read(persona).IsEmpty() && !Write(persona, legacy))
+ {
+ return false;
+ }
+
+ clearLegacy();
+ return true;
+ }
+
+ private static PersonaGUID DerivePersona(string seed)
+ {
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(PersonaNamespace + seed));
+ return SemanticString.Create(new Guid(hash.AsSpan(0, 16)).ToString());
+ }
+
+ ///
+ /// Recognises a machine with no usable secret store: the factory refusing the platform, the
+ /// native library failing to resolve, or the store itself reporting a failure.
+ ///
+ private static bool IsUnavailable(Exception exception) =>
+ exception is PlatformNotSupportedException
+ or DllNotFoundException
+ or EntryPointNotFoundException
+ or CredentialStoreException;
+
+ ///
+ /// Logs the unavailable store once per process. Tokens are read on request paths that run every
+ /// few seconds, so reporting each failure would bury the log.
+ ///
+ private static void ReportUnavailable(Exception exception)
+ {
+ if (Interlocked.Exchange(ref _unavailableReported, 1) == 0)
+ {
+ Log.Error($"{UnavailableMessage} ({exception.Message})");
+ }
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index ab17a78..42aa1f1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -170,13 +170,27 @@ The application implements sophisticated rate limit management to avoid hitting
### Authentication and Credentials
+**Where tokens live:**
+- Access tokens are held in the OS secret store (Windows Credential Manager, macOS Keychain,
+ libsecret on Linux) through `TokenStorage`, not in the app data file
+- `TokenStorage` derives a persona per token from a versioned namespace plus
+ `BuildProviderName` (and `OwnerName` for an override), so `AppData` holds no credential-shaped
+ state at all. Changing the derivation orphans every token already stored, which is why the
+ namespace carries a version
+- `BuildProvider.LegacyToken` and `Owner.LegacyToken` map to the old `Token` JSON field and exist
+ only for the one-time migration `OnStart` runs: the token is written to the secret store first,
+ then blanked in the app data file
+- With no usable secret store, tokens read as empty and the reason is logged once. BuildMonitor is
+ a desktop app, so a throw out of a token read would take down the render loop. It never falls
+ back to writing tokens to a plain file
+
**Provider-Level Authentication:**
-- Each provider has an `AccountId` and `Token` (stored in AppData, persisted)
+- Each provider has an `AccountId` (stored in AppData) and a `Token` (stored in the OS secret store)
- Set via "Set Credentials" menu item (two-step popup: AccountId, then Token)
- Cleared automatically on `AuthorizationException` or 403 Forbidden responses
**Owner-Level Authentication (GitHub only):**
-- Each owner can have an optional `Token` property (overrides provider token)
+- Each owner can have an optional `Token` property (overrides provider token), also held in the OS secret store
- Enables access to private repositories in different organizations
- Set via "Providers → GitHub → Set Owner Token" submenu
- Clear via "Providers → GitHub → Set Owner Token → Clear Owner Token" submenu
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 53dcbcc..ce8d580 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,6 +5,7 @@
+
diff --git a/README.md b/README.md
index 72463db..32d66cb 100644
--- a/README.md
+++ b/README.md
@@ -131,6 +131,10 @@ Full support for GitHub Actions including:
1. Go to **Providers > GitHub > Set Credentials**
2. Enter your GitHub username
3. Enter a Personal Access Token (PAT) with `repo` and `workflow` scopes
+
+Tokens are kept in the operating system's secret store — Windows Credential Manager, macOS Keychain,
+or libsecret (Secret Service) on Linux — not in the app data file. A token saved by an earlier
+version is moved there on the next start and blanked where it was, so nothing needs re-entering.
4. Add owners via **Providers > GitHub > Add Owner**
Additional providers are planned for future releases.