feat: keep GitHub tokens in the OS secret store [minor] - #424
matt-edmondson wants to merge 3 commits into
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
| foreach (GitHubOwnerName owner in OwnersInDisplayOrder(Options.GitHubOwners)) | ||
| { | ||
| if (ImGui.MenuItem(owner)) | ||
| { | ||
| OwnerPendingTokenPopup = owner; | ||
| } | ||
| } |
There was a problem hiding this comment.
Checked this one rather than applying it, and I'm leaving the loop as it is: the rule does not hold here, and the suggested rewrite would introduce a real defect.
ImGui.MenuItem(owner) is not a predicate. It draws the menu item, and its return value reports whether that item was clicked during this frame. This is Dear ImGui's immediate-mode idiom: the call is the rendering, and the bool is the event. The analysis reads it as a filter because it sits in an if at the top of a loop body, but there is nothing being filtered — every owner is drawn on every frame, and at most one of them was clicked.
Moving it into .Where(owner => ImGui.MenuItem(owner)) would make drawing the menu a side effect of evaluating a LINQ predicate. That happens to render correctly today only because foreach enumerates the sequence exactly once, eagerly, to completion. It breaks the moment anything treats that sequence as a sequence:
.Any()or.First()short-circuits, and the owners after the first click stop being drawn;.Count()or a second enumeration draws every item twice in one frame, which Dear ImGui reads as duplicate IDs;- evaluating the enumerable in a debugger watch window draws menu items as a side effect of inspecting it.
Each of those is a frame-dependent UI bug, and none would be caught by a test. Trading that for an explicit .Where(...) is not worth it when the if already says exactly what is happening.
On the second half of the suggestion — adding using System.Linq; — that is already unnecessary: this file uses LINQ a few lines above, in OwnersInDisplayOrder(...), and System.Linq is an implicit global using under this SDK.
Happy to revisit if the intent is a broader convention about immediate-mode loops, but as a change to this loop it makes the code less correct rather than more.
Generated by Claude Code
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 |
|


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