…inor]
GitHub and Azure DevOps personal access tokens, usually scoped to repo and
workflow, were plain [JsonInclude] fields on BuildProvider and Owner. Every
QueueSaveAppData serialized the whole object graph to an unencrypted file
under the user's app data directory, readable by anything running as the
same user and picked up by any backup or file-sync tool covering it.
TokenStorage now holds them in the platform-native secret store through
ktsu.CredentialCache, under a service name scoped to BuildMonitor.
Personas are derived rather than stored: a versioned namespace plus the
provider name, and the owner name as well for an override. AppData is left
with no credential-shaped state at all, and the owner-shadows-provider
semantics stay a lookup order rather than extra persisted fields. The
namespace carries a version because changing the derivation would orphan
every token already in the store.
LegacyToken keeps the old Token JSON name so OnStart can migrate: written to
the secret store first, so a store that throws cannot lose it, and only then
blanked in the file. A token already in the store wins over a stale copy, but
the stale copy is still cleared — ceasing to write a secret does not remove
the one already on disk.
With no usable secret store, tokens read as empty and the reason is logged
once rather than on every request path. A throw out of a token read would
take down the render loop of a desktop app. There is no plaintext fallback.
Fixes #278
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AnVpPWKjVtXnAvd4bnzUL
Fixes #278.
The exposure
BuildProvider.TokenandOwner.Tokenwere plain[JsonInclude]fields. Both hang offAppData.BuildProviders, whichktsu.AppDataStorageserializes whole and writes to an unencrypted file on everyQueueSaveAppData()— and that is called immediately after a token is entered. GitHub and Azure DevOps PATs, usually scoped torepo/workflow, were sitting in plaintext JSON under the user's app data directory.They now go to the platform-native secret store through
ktsu.CredentialCache, under a service name scoped toktsu.BuildMonitorso they cannot collide with another ktsu tool's credentials on a shared host.Design
Personas are derived, not stored. The triage preferred this over persisting a
PersonaGUID, and it holds up: a versioned namespace plus the provider name, and the owner name as well for an override, hashed to a stable GUID.AppDatais left with no credential-shaped state at all, and the owner-shadows-provider semantics stay a lookup order rather than extra persisted fields. The namespace carries a/v1/because changing the derivation would orphan every token already in the store — a future change has to be a deliberate, visible one.Scoping the owner persona by provider matters: the same organization name can exist on both GitHub and Azure DevOps, and
OwnerPersonaIsNotTheProviderPersonapins that an override can never land on the provider's own entry.Migration.
LegacyTokenkeeps the oldTokenJSON name soOnStartcan find what earlier versions wrote. Order is load-bearing: the token is written to the secret store first, so a store that throws cannot lose it, and only then blanked in the file. If the store refuses the write, the old copy is deliberately left alone — clearing it would destroy the only copy the user has. A token already in the store wins over a stale legacy one, but the stale one is still cleared, because ceasing to write a secret does not remove the one already on disk.No secret store. This is where BuildMonitor departs from its sibling ktsu-dev/OAICLI#42, deliberately. OAICLI is a CLI and throws with an explanation. BuildMonitor is a desktop app whose token reads sit on request paths running every few seconds — an exception out of a read would take down the render loop, and throwing once per read would bury the log. So a missing store reads as "no token", is logged once per process with the cause and the remedy, and surfaces through the existing
AuthFailedprovider status. What it never does is fall back to a plain file.Tokenkeeps its original accessibility (protectedget,privateset), so no call site outsideBuildProvidergained any reach it did not have.Tests
BuildMonitor.Test/TokenStorageTests.cs, 14 new tests on top of the existing 34, driven through aBuildProviderstub with no network behaviour and anInMemoryCredentialStore. The no-store path uses a store whose every operation throwsDllNotFoundException, which is what a missinglibsecret-1.so.0looks like.Proven to fail without the fix, by three mutations on an otherwise unchanged tree:
MigrationBlanksThePlaintextCopies,MigrationIsIdempotent,MigrationKeepsTheStoredTokenAndStillClearsTheStaleOneMigrationKeepsTheLegacyTokenWhenTheStoreRefusesSerializedProviderCarriesNoTokenOne note on that last test, because it was wrong first time and the mutation is what caught it.
SemanticStringimplementsIEnumerable<char>, so under bareJsonSerializerOptionsa token serializes as{"$values":["g","h","p",…]}and a substring assertion for the token value never matches — the test passed for the wrong reason. It now builds its options the wayAppDataStoragedoes, withRoundTripStringJsonConverterFactoryregistered, so the check is against the representation actually written to disk. It also asserts the legacy"Token"field serializes empty, so the migration's own target is pinned too.All 48 pass with the implementation in place, and the solution builds clean with no warnings.
Docs
CLAUDE.md's Authentication and Credentials section now records where tokens live, the persona derivation and why it is versioned, the migration, and the no-store decision. README's GitHub setup section notes the secret store and that an existing token is moved automatically.Note for the rest of the cluster
ktsu-dev/ProjectDirector#411 is the remaining one, and it is the same multi-credential shape as this. The derived-persona approach here should port directly — a namespace plus whatever names identify the credential — as should the write-then-clear migration order and the data-loss guard when the store refuses.
🤖 Generated with Claude Code
https://claude.ai/code/session_018AnVpPWKjVtXnAvd4bnzUL
Generated by Claude Code