Give each process its own keyring probe item, and say when the keyring fell back to file - #70
Conversation
…g failure on fallback Every invocation probed keyring availability by writing and deleting one fixed-name entry, credstore.probe.<service> / __probe__. Twenty concurrent invocations therefore churned one keychain item, and on darwin `security add-generic-password -U` is find-then-create inside the security tool: a peer's delete or add landing in that window fails the add with errSecDuplicateItem (rc=45) on a perfectly healthy keychain. The loser silently switched to the plaintext file fallback, so a machine whose credentials live in the keychain answered "credentials not found for profile:<name>" — and where a months-stale credentials.json survived, it answered with expired tokens instead. Observed today from a connector that fans out CLI calls: 19 of 20 parallel `basecamp auth status` reported stale or missing credentials; serial runs were 20/20 fine. #69 taught the probe to tolerate the lost race (accept the duplicate-item answer; retry a failed Set and read back a peer's entry). That closed the bounded darwin path but left the unbounded go-keyring path losing 149 of 200 probes at 20-way concurrency — the retry loses too and the peer has already deleted its entry — and it was armor around the actual defect: a shared item. Now the probe account is __probe__.<pid>, and NewStore serializes probes within a process, so no two in-flight probes ever touch the same entry. The duplicate-item and retry/read-back recovery is retired as unreachable. The account stays deterministic per pid rather than random so a leaked entry still self-heals — the next process reusing that pid overwrites and removes it — instead of becoming unfindable, go-keyring having no list API. Measured against a real keychain, 20 parallel probes x 10 rounds: raw `security` on one shared account 190/200 failed (all rc=45), on per-pid accounts 0/200; credstore at the pre-#69 pin 190/200 both paths, at #69 bounded 0/200 but unbounded 149/200, with this change 0/200 and 0/500 on both paths. A probe failure is also no longer silent on read. The store keeps the probe error (ProbeError), the fallback warning names it, and a miss on the file fallback says "system keyring unavailable (<reason>), fell back to <path>" rather than a bare "credentials not found". The darwin probe folds the security tool's diagnostic into its error and a timed-out probe says so, so the reason is readable rather than "exit status 36".
|
@codex review |
There was a problem hiding this comment.
Pull request overview
Adds per-process keyring probes and exposes file-fallback causes to callers.
Changes:
- Uses PID-scoped probe entries with in-process serialization.
- Reports probe failures through warnings and load errors.
- Expands regression coverage for concurrency, timeouts, and diagnostics.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
credstore/store.go |
Stores and surfaces probe failures; serializes probes. |
credstore/store_test.go |
Tests fallback reporting and serialization. |
credstore/probe.go |
Introduces PID-scoped probe accounts. |
credstore/probe_test.go |
Tests probe naming and cleanup. |
credstore/probe_other.go |
Documents non-darwin timeout behavior. |
credstore/probe_darwin.go |
Includes keychain diagnostics in errors. |
credstore/probe_darwin_test.go |
Tests isolation, timeouts, and diagnostics. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 969ac6243a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
A per-process account plus an in-process mutex left one gap: on non-darwin, a bounded probe that times out abandons its worker goroutine mid-Set, and releasing the mutex then let a later NewStore in the same process probe under the same pid account while that worker was still running against it. Holding the mutex until the worker finished would have made the next NewStore wait on the very hang the timeout exists to escape. An in-process sequence number in the account — __probe__.<pid>.<n> — gives every probe its own entry, which makes the mutex unnecessary and removes it. Leaks still self-heal on pid reuse: the next process with that pid overwrites and removes the same-numbered leftover, and in practice a process probes once, so that is entry 1. Real keychain, 20 parallel x 10 rounds: still 0/200 on both paths.
|
@codex review |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Only the bounded probe folded security's diagnostic into its error. The unbounded probe goes through go-keyring, whose darwin Set returns cmd.Wait()'s bare "exit status N" and discards the diagnostic line — and the unbounded probe is the interactive path, every session with a terminal. So a headless fallback read "User interaction is not allowed. (exit status 36)" while an interactive user with a locked keychain got "system keyring unavailable (exit status 36)": a number, not a reason. security exits with the low byte of the SecBase.h OSStatus, so the codes are stable. A darwin-only table names the ones an add can produce on an unavailable keychain (36, 37, 45, 50, 51, 52, 53, 128) with the text `security error <OSStatus>` prints, in the same "<reason> (exit status N)" shape as the bounded path. Unknown exit statuses and non-exit errors pass through unchanged; other platforms' backends run in-process and already name their failures.
The rewrite of NewStore replaced TestZeroValueOptionsProbeUnbounded, the only test of a successful probe, with the fallback-side tests, so nothing asserted that a healthy probe keeps the keyring: a mutant that always fell back to the plaintext file (useKeyring: false) passed the suite. Restore the happy path — UsingKeyring true, ProbeError nil, no warning — and the zero-timeout contract that tests mocking the keyring rely on.
|
Since 1b3e5b5 (two commits,
@codex review |
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…y on login (#664) * auth: warn about the keyring fallback on the first read, not only on login The credential store printed its "system keyring unavailable, credentials stored in plaintext" warning only on Save. Every other command merely reads, so a process whose keyring probe failed served whatever an earlier fallback had left in credentials.json — on one machine today, tokens that expired months ago and profiles that no longer existed — with no word about why. The failing probe was invisible until the next login. Store.Load now runs the same once-per-process warning as Save, so the first read after a fallen-back probe says so on stderr, and once credstore is bumped past basecamp/cli#70 the warning and the Load error also name the probe failure itself. Hosts that mean to use file storage set BASECAMP_NO_KEYRING, which skips the probe and never warns. The wrapper's inner store is now the credStore interface rather than the concrete *credstore.Store, so the test can stand in a fallen-back store without failing a real keyring probe. * auth: reword the credStore seam comment * auth: keep a BASECAMP_TOKEN session off the store in SetUserEmail `basecamp people me` stores the fetched email through SetUserEmail, the one read path with no BASECAMP_TOKEN guard: IsAuthenticated and AuthorizationEndpoint short-circuit on the env token, then SetUserEmail loads the stored credentials anyway. That was wrong twice over. The email names the env token's user, not whoever the stored credentials belong to, so writing it there mislabeled them. And the load ran the keyring probe — now that a fallback read warns, a CI host with a locked keychain and only BASECAMP_TOKEN warned about plaintext credentials it neither stored nor read. BASECAMP_TOKEN wins, matching AccessToken() and AccountID(): SetUserEmail returns without touching the store.
Pulls in basecamp/cli#70: the keyring availability probe now writes a per-probe keychain item (service credstore.probe.<name>, account __probe__.<pid>.<n>), so concurrent CLI processes no longer share one probe entry and race each other's read-back. With the previous pin, 20 parallel invocations against the real macOS keychain returned 19 file-store fallbacks reading stale or missing credentials; with this pin all 20 read the keyring. FallbackWarning() now carries the probe failure reason, so the fallback warning added in #664 names its cause. vendorHash recomputed and the Nix build verified via make update-nix-hash.
Pulls in basecamp/cli#70: the keyring availability probe now writes a per-probe keychain item (service credstore.probe.<name>, account __probe__.<pid>.<n>), so concurrent CLI processes no longer share one probe entry and race each other's read-back. With the previous pin, 20 parallel invocations against the real macOS keychain returned 19 file-store fallbacks reading stale or missing credentials; with this pin all 20 read the keyring. FallbackWarning() now carries the probe failure reason, so the fallback warning added in #664 names its cause. vendorHash recomputed and the Nix build verified via make update-nix-hash.
The failure
Every invocation probes keyring availability by writing and deleting one fixed-name entry,
credstore.probe.<service>/__probe__. Twenty concurrent invocations churn one keychain item, and on darwinsecurity add-generic-password -Uis find-then-create inside the security tool: a peer's delete or add landing in that window fails the add with errSecDuplicateItem (rc=45) on a perfectly healthy keychain. The loser silently switched to the plaintext file fallback, so a machine whose credentials live in the keychain answeredcredentials not found for profile:<name>— and where a months-stalecredentials.jsonsurvived, it answered with expired tokens instead.Observed today from a connector that fans out
basecampcalls: 19 of 20 parallelbasecamp auth status -jreturned the stale file,basecamp me --profile clawditosaid "Not authenticated" 19/20, and one Basecamp event was dropped as "not corroborated". Serial runs: 20/20 fine.#69 taught the probe to tolerate the lost race. That closed the bounded darwin path but left the unbounded go-keyring path (interactive sessions) losing 149 of 200 probes at 20-way concurrency — the retry loses too and the peer has already deleted its entry — and it was armor around the actual defect: a shared item.
The fix
Per-probe entry. The account is now
__probe__.<pid>.<n>— pid plus an in-process sequence number — derived once inprobe()so both the bounded (security -i) and unbounded (go-keyring) paths use it. The pid separates processes; the sequence separates probes within one, including a timed-out probe's abandoned worker (non-darwin) from any later probe, so no two probes ever touch the same keychain item. The credstore: don't mistake a lost probe-write race for an unavailable keyring #69 recovery (duplicate-item tolerance, Set retry, Get read-back) is retired as unreachable.The account is deterministic rather than random so a leaked entry (timed-out probe, cleanup cut short) still self-heals: the next process to reuse the pid overwrites and removes the same-numbered leftover — entry 1 in practice, since a process probes once — instead of the entry becoming unfindable (go-keyring has no list API).
(The first commit used
__probe__.<pid>plus an in-process mutex; review caught that a timed-out non-darwin probe keeps running on that account after the mutex is released. The sequence number closes that and obviates the mutex — second commit.)A fallback is never silent about its cause.
Store.ProbeError()returns why the probe failed (nil when the keyring is in use or file storage was requested viaForceFile/DisableEnvVar).FallbackWarning()names the reason:system keyring unavailable (<reason>), credentials stored in plaintext at <path>.credentials not found for profile:x: system keyring unavailable (<reason>), fell back to <path>—basecamp-cliwraps that verbatim inNot authenticated for %s: %v, so the reason reaches users with no change there.security -i, headless sessions) capturessecurity's own diagnostic line. The unbounded probe (go-keyring, every interactive session) only ever sees a bareexit status N— go-keyring's darwinSetreturnscmd.Wait()and discards the diagnostic — so a darwin-only table maps the exit status (the low byte of the SecBase.h OSStatus: 36, 37, 45, 50, 51, 52, 53, 128) to the textsecurity error <OSStatus>prints, in the same shape:User interaction is not allowed. (exit status 36). Unknown exit statuses and non-exit errors pass through unchanged. A timed-out probe sayskeyring probe timed out after 10s.Measurements (real keychain on this machine, 20 parallel × 10 rounds)
security -i)security add -U+ delete, one shared accountsecurity, per-pid accounts04e401b(what basecamp-cli 0.9.1 pins)main(#69)Harness: a 15-line program calling
credstore.NewStore{ServiceName: "repro"}and printingUsingKeyring(), run N-way in parallel from a shell loop.Tests
TestProbeContractIsPerProbeAndReserved,TestProbeUsesIsolatedPerProbeEntry(darwin),TestProbeDirectUsesPerProbeEntry— both paths derive__probe__.<pid>.<n>under the reserved service, and consecutive probes get distinct accounts.TestProbeFailureIsReportedOnLoad,TestProbeTimeoutFallsBackToFile,TestProbeBoundedFailureCarriesDiagnostic,TestProbeDirectNamesSecurityExitStatus(darwin),TestProbeDirectPassesUnknownExitStatusThrough(darwin),TestProbeTimeoutIsNamed— the probe error is kept and named in the warning and in the fallback Load error, on the bounded and the unbounded path.TestHealthyProbeUsesKeyring— a successful probe keeps the keyring (UsingKeyring()true,ProbeError()nil, no warning) and zero-value options reach the probe with a zero timeout, the documented contract for tests that mock the keyring. A mutant that always falls back (useKeyring: false) fails this and nothing else.TestProbeFailureStillReadsFallbackFile,TestRequestedFileStorageReportsNoProbeFailure— file-only hosts keep working; requested file storage is not a "fallback" and gets no warning.Checked against the pre-change code (with compile-only shims for
ProbeErrorandprobeKey()returning the old behavior): the contract, per-probe-entry, timeout-naming, diagnostic, and Load/warning tests all fail there;TestProbeDirectNamesSecurityExitStatusfails withprobeDirectreturning the error verbatim ("exit status 36").make check,golangci-lint run, andgo test -race ./credstore/pass.Declined
-U's duplicate detection and its find, errSecItemNotFound), and go-keyring's unbounded path can't see the code at all — hence 149/200.NewStorewait on the very hang the timeout exists to escape.delete-generic-password -s <svc>without-a, looped). It would delete a concurrent peer's in-flight entry — benign, since its add already succeeded, but it re-couples processes that this change decouples, for a leak that is already harmless (a "probe" string in a reserved namespace) and self-healing.~/.config/basecamp/.migratedmarks the bcq→basecamp keyring-service migration, not file→keyring, so it is not evidence the file is stale;MigrateToKeyringisn't called by basecamp-cli at all. And credstore cannot tell "keyring never worked here" (Linux without a secret service, whose credentials legitimately live only in the file) from "the probe failed this once". When the file is absent, the Load error already carries the keyring reason and the path, which is the same information. The right layer for "don't trust a stale file" is naming the failure, which this does.security's diagnostic on the unbounded path by drivingsecurity -ithere too. That is the bounded path. The unbounded probe goes through go-keyring on purpose sokeyring.MockInitis honored (documented onProbeTimeout); go-keyring's darwinSetsets no stdout/stderr, so the diagnostic line is gone by the time the error reaches us and only the exit status survives.security error. Only the low byte of the OSStatus survives the exit, so the full code can't be recovered for lookup; a fixed table of the eight codes an add can produce on an unavailable keychain is exact, and needs no second exec on an already-failing keychain.FallbackWarning()now carries the reason and the docstring asks callers to surface it on reads too. basecamp-cli'sStore.Loadwill warn once per process (separate PR there).Follow-ups
github.com/basecamp/clipast this merge (go get github.com/basecamp/cli@main && go mod tidy) and warn on fallback reads as well as writes.