feat: keep GitHub tokens in the OS secret store [minor] - #424
Conversation
ProjectDirectorOptions carried a personal access token per configured owner, plus an account-level one, in plain fields. AppData<T> serializes the whole object, so every debounced save wrote those PATs to an unencrypted settings file alongside window state and UI preferences. TokenStorage now holds them in the platform-native secret store through ktsu.CredentialCache, under a service name scoped to ProjectDirector. Personas are derived rather than stored: a versioned namespace plus the login or the owner name. GitHubOwners becomes a set of names — it was doing double duty as both the owner registry and the token map — so the settings file keeps tracking which owners are configured while holding no secret. LegacyGitHubToken and LegacyGitHubOwners keep the old JSON names so startup can migrate: written to the secret store first, so a store that throws cannot lose them, and only then emptied. A token already in the store wins over a stale copy, but the stale copy is still cleared. There was no way to enter a token short of editing the settings file by hand, so moving storage without adding one would have left new setups with no way at all. File > Set GitHub Owner Token fills that in, alongside the existing Add New GitHub Owner. With no usable secret store, tokens read as empty and the reason reaches the log once rather than on every owner of every scan. A throw out of a token read would take down the render loop. There is no plaintext fallback. DevDirectory defaulted to the literal C:\dev, which AbsoluteDirectoryPath rejects off Windows, so constructing the options threw there and no test could touch the type. It now picks a valid path per platform, unchanged on Windows. Fixes #411 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AnVpPWKjVtXnAvd4bnzUL
CI status:
|
SonarCloud's quality gate failed the PR on new-code coverage: 62.6% against a required 80%. TokenStorage and ProjectDirectorOptions were already covered; every uncovered new line was in ProjectDirector.cs, which has no tests at all. CLAUDE.md already says what to do about that: the part with a rule in it is pulled out into a plain method so it can be driven without a live ImGui context or a display. Four were still tangled up with drawing. ResolveGitHubCredentials is the important one. An owner's own token shadowing the account-level token is what makes a private repository in another organization reachable, and it was an inline ternary in the scan loop with nothing pinning it. It now also answers null rather than building credentials around a blank secret, so a misconfigured owner produces an anonymous request instead of authenticating as nobody. ApplyOwnerToken carries the refusal case: the popup closes whether or not the secret store took the token, so a silent failure would look exactly like success. OwnersInDisplayOrder removes a duplicated sort. The owner registry became a set in this branch, and a set does not promise an order, so the owner panels and the token menu could otherwise disagree with each other and between runs. DescribeTokenMigration is the startup log line. What is left uncovered in ProjectDirector.cs is opening popups, drawing the menu and assigning to the client — drawing and wiring, with no rule left in them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AnVpPWKjVtXnAvd4bnzUL
Migration has to run before the account token is read. For a user upgrading from a version that kept the token in the settings file, the token only exists in the secret store once migration has put it there, so resolving credentials first would start that session unauthenticated and only pick the credentials up on the next launch. That ordering was implicit in the constructor, where nothing could test it. PrepareTokens makes it one plain method with the reason written down, and StartupMigratesBeforeResolvingCredentials fails if the two are swapped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AnVpPWKjVtXnAvd4bnzUL
SonarCloud new-code coverage: what I did, and the one thing I can't do from hereThe quality gate failed on new-code coverage, and I've taken it as far as it honestly goes. 62.6% → 67.6% → ~73% across two pushes. It will not reach 80%, and I don't think it should be made to. What was actually uncovered
So I followed that rule rather than arguing with the gate. Five decisions were still tangled up with drawing, and each is now a plain method with a test:
12 tests added for those; 61 pass. What's left, and why I stopped48 uncovered lines, all of them drawing and wiring: declaring popup fields, This PR is also unusually exposed to that gate: it had to add UI. There was previously no way to enter a token at all short of hand-editing the settings JSON, so moving storage without adding an input path would have left new setups with no way whatsoever. The mechanism already exists — it just isn't set for this repoThe shared
That is a repository setting, not a code change, so I can't do it from a PR — and I wouldn't want to anyway: it changes the coverage bar for every future PR in this repo, which is a maintainer's call, not mine. So this needs a decision:
I've deliberately not tried to route around it. Everything else on the PR is green except Generated by Claude Code |
…r-credentialcache # Conflicts: # Directory.Packages.props # ProjectDirector/ProjectDirector.cs # ProjectDirector/ProjectDirectorOptions.cs
|
Merged
Taking either side alone regresses something, so the merge takes both:
Verified on the merge result: Generated by Claude Code |
|
The coverage gate is the same blocker as before, not a regression from the mergeSonarCloud went red again on the merge commit — 70.6% new-code coverage, required ≥ 80%. This is the blocker already analysed above, not a new one, and the decision it asks for is unchanged. Adding this only because that comment cites ~73% and the reading is now 70.6%, which would otherwise look like the merge cost coverage. It didn't. What the merge contributed to ApplyCredentials(GitHubClient, owner, TokenStorage.ReadOwnerToken(owner), Options.GitHubLogin, Options.GitHubToken);It sits in The bulk of the movement is Still nothing to pushThe uncovered remainder is what it was: drawing and wiring — popup fields, Manufacturing further seams around The decision is still yoursUnchanged from the earlier comment — set Everything else on the current head is green: tests pass on ubuntu, windows and macOS, CodeQL and Generated by Claude Code |


Fixes #411. Completes the three-repo cluster with ktsu-dev/OAICLI#42 and ktsu-dev/BuildMonitor#278.
The exposure
ProjectDirectorOptionscarried a PAT per configured owner plus an account-level one, in plain fields with no[JsonIgnore].AppData<T>serializes the whole object, so every debounced save wrote those tokens to an unencrypted settings file alongside window state and divider positions.They now go to the platform-native secret store through
ktsu.CredentialCache, under a service name scoped toktsu.ProjectDirector.Design
Personas are derived, not stored — a versioned namespace plus the login or the owner name, hashed to a stable GUID. The
/v1/is there because changing the derivation would orphan every token already in the store, so a future change has to be deliberate.GitHubOwnersbecomes a set of names. It was doing double duty: the registry of configured owners and the map to their tokens. Splitting those is what lets the settings file keep tracking which owners are configured while holding nothing credential-shaped. This is the public-shape change the issue anticipated.Migration, not re-entry. The issue's caveat assumed users would re-enter their PAT because there is nothing in the OS store to migrate from — but there is something to migrate to, and the plaintext copy needs clearing regardless. So startup moves both the account token and every owner token, registers the owner names, and empties the old fields. Order is load-bearing: written to the store first, so a store that throws cannot lose the token; if the write is refused the old copy is deliberately left alone, because clearing it would destroy the only copy the user has.
One thing the issue did not cover
There was no way to enter a token at all. Owners were seeded with
GitHubToken.Create<GitHubToken>(string.Empty)and nothing ever populated them — the only way to set a PAT was editing the settings JSON by hand. Moving storage without adding an input path would have left new setups with no way whatsoever, so this adds File > Set GitHub Owner Token next to the existing Add New GitHub Owner, following the deferred-open pattern the codebase already uses (a popup cannot be opened from insideBeginMenu).I could not exercise the ImGui layer here — this container is headless, and per CLAUDE.md the ImGui layer is not unit-tested in this repo either. The storage and migration underneath it are fully covered, which is the split CLAUDE.md asks for.
No secret store available. Same decision as BuildMonitor and for the same reason: a desktop app cannot throw out of a token read without taking down the render loop, and these reads happen per owner on every scan. So it reads as "no token", and
TokenStoragerecords the reason forProjectDirectorto drain into its log exactly once. There is no plaintext fallback.One unrelated fix, which the tests needed
DevDirectorydefaulted to the literalC:\dev, whichAbsoluteDirectoryPathrejects off Windows — sonew ProjectDirectorOptions()threw on Linux and macOS, and no test could construct the type at all. It now picks a valid path per platform, unchanged on Windows. Flagging it rather than burying it: it is not part of the issue, but without it none of the migration tests below can run, and it is latent today only because no existing test touches the options type.Tests
ProjectDirector.Test/TokenStorageTests.cs, 14 new tests on top of the existing 38. Proven to fail without the fix, by three mutations:MigrationEmptiesThePlaintextFields,SerializedOptionsCarryNoTokenMigrationKeepsTheLegacyTokenWhenTheStoreRefusesSerializedOptionsCarryNoTokenSerializedOptionsCarryNoTokenbuilds its serializer the wayAppDataStoragedoes, withRoundTripStringJsonConverterFactoryregistered — without it a semantic string serializes as a char array and a substring assertion for the token silently never matches.All 52 pass, and the solution builds clean with no warnings.
Adjacent, not touched
ProjectDirectorOptions.OpenAITokenis the same shape of secret in the same file, but its only consumer is a commented-out line, so it is dead rather than exposed. Left alone as outside this issue — worth its own decision about whether it should exist at all.🤖 Generated with Claude Code
https://claude.ai/code/session_018AnVpPWKjVtXnAvd4bnzUL
Generated by Claude Code