Skip to content

fix(oauth): store keyring tokens as one entry per provider - #668

Open
euxaristia wants to merge 28 commits into
Gitlawb:mainfrom
euxaristia:fix/keyring-oauth-per-provider-entries
Open

fix(oauth): store keyring tokens as one entry per provider#668
euxaristia wants to merge 28 commits into
Gitlawb:mainfrom
euxaristia:fix/keyring-oauth-per-provider-entries

Conversation

@euxaristia

@euxaristia euxaristia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

PR #574 moved the macOS keyring write path to security -i, which is
necessary to keep the secret out of the process list and out of a
getpass(3) /dev/tty prompt. But security -i's command parser caps a
single write at 4095 bytes, and the oauth store combines every
provider and MCP token into one JSON blob under a single keyring
entry. That blob has no size bound of its own: three or more
logged-in providers routinely exceeds the cap, so Set() starts failing
for every provider, not just the one that pushed it over the line.

This splits keyring storage into one entry per token key (account =
key), plus a small index entry listing which keys currently exist,
since KeyringClient has no list operation. Each write is now bounded
to a single token's size, comfortably under the 4095-byte cap
regardless of how many providers are logged in.

Installs still on the old combined-entry format keep reading
correctly through a legacy fallback, and get migrated to per-key
entries (with the legacy entry removed) the next time anything is
saved.

Test plan

  • go build ./...
  • make lint (fmt-check + vet)
  • go test ./internal/oauth/... ./internal/keyring/... ./internal/credstore/... -race
  • New test simulates 5 logged-in providers with realistic JWT-sized
    tokens and asserts no single keyring entry exceeds a 3000-byte
    margin under the line cap
  • New test covers migration from the legacy combined-entry format
  • Verified against the real macOS security CLI in a throwaway
    test keychain that the underlying security -i write/read
    mechanism this builds on works correctly (quoting, round-trip,
    length guard)

Summary by CodeRabbit

  • Improvements
    • OAuth token storage now uses per-token indexed entries with safer legacy migration and recovery.
    • Improved keyring coordination during reads, writes, and long-running operations.
    • Token refresh now preserves existing scopes when the server omits scope information.
  • Bug Fixes
    • Improved handling of missing or inconsistent keyring entries and indexes.
    • Lock acquisition is more reliable during stalled or long-running operations.
  • Tests
    • Expanded coverage for migration, recovery, corruption limits, locking, and scope preservation.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

OAuth keyring storage now uses individually encoded token entries coordinated by a bounded chunked index. Reads recover legacy and partially written data. Writes reconcile migrations, interruptions, deletions, and mixed-version updates. Refresh preserves existing scopes when responses omit them.

Changes

OAuth keyring storage

Layer / File(s) Summary
Keyring format and read recovery
internal/oauth/store.go, internal/oauth/store_keyring_test.go
The backend reconstructs tokens from per-key entries, validates bounded indexes, filters invalid and duplicate keys, tolerates missing entries, and recovers legacy data.
Indexed writes and migration reconciliation
internal/oauth/store.go, internal/oauth/store_keyring_test.go
Writes reconcile legacy and indexed tokens, persist tombstones, publish union indexes, update per-key entries, clean up obsolete data, and handle interrupted operations.
Read and write locking
internal/oauth/store.go, internal/oauth/lock.go, internal/oauth/store_keyring_test.go
Keyring operations use identity-scoped leased locks with wall-clock deadlines. File reads remain lock-free.

OAuth refresh scopes

Layer / File(s) Summary
Refresh scope preservation
internal/oauth/flow.go, internal/oauth/flow_test.go
Refresh retains current token scopes when the response omits scopes and uses configured scopes only when current scopes are empty.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Store
  participant keyIndex
  participant tokenEntries
  participant legacyEntry
  Store->>keyIndex: Read and validate index
  keyIndex-->>Store: Return token keys
  Store->>tokenEntries: Read token entries
  tokenEntries-->>Store: Return available tokens
  Store->>legacyEntry: Reconcile legacy tokens
  Store->>keyIndex: Publish updated key set
Loading

Suggested reviewers: gnanam1990

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: storing keyring tokens in separate entries per provider.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/oauth/store_keyring_test.go (1)

131-172: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider testing the index/entry desync recovery path.

Migration and deletion-reconciliation coverage look solid. One gap: read()'s continue when a key is listed in the index but its entry is missing (store.go Lines 483-488) — the mechanism the design relies on for surviving interrupted writes — isn't exercised anywhere. A small test seeding an index that references a key with no corresponding entry (mimicking a killed process mid-write) would lock in that behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store_keyring_test.go` around lines 131 - 172, Add a focused
test for the keyring store’s index/entry desynchronization recovery in the read
path, such as the relevant Store read method. Seed the fake keyring with an
index referencing one missing entry and at least one valid entry, then verify
reading skips the missing key without returning an error and still returns the
valid token. Model the setup after TestStoreKeyringMigratesLegacyCombinedEntry
and use the existing fake keyring helpers and keyring symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/oauth/store.go`:
- Around line 447-510: Update Store.Load’s keyring path to execute
keyringBlob.read() through blob.withLock, using the same cross-process locking
established for Save/Delete. Ensure the lock covers the complete index-and-entry
read so concurrent writes cannot produce a stale or incomplete result; preserve
existing read errors and returned data.

---

Nitpick comments:
In `@internal/oauth/store_keyring_test.go`:
- Around line 131-172: Add a focused test for the keyring store’s index/entry
desynchronization recovery in the read path, such as the relevant Store read
method. Seed the fake keyring with an index referencing one missing entry and at
least one valid entry, then verify reading skips the missing key without
returning an error and still returns the valid token. Model the setup after
TestStoreKeyringMigratesLegacyCombinedEntry and use the existing fake keyring
helpers and keyring symbols.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 23533b19-6702-40c4-8c89-873eaeb55792

📥 Commits

Reviewing files that changed from the base of the PR and between 80c39aa and 8eac9aa.

📒 Files selected for processing (2)
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go

Comment thread internal/oauth/store.go
@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed CodeRabbit's review.

  • Store.Load now runs its read through blob.withLock, matching Save/Delete. The keyring backend's read is several separate Get calls (index, then each entry), not one atomic snapshot, so an unlocked Load could run concurrently with another process's Save/Delete mid write and observe a torn state. I extended the same fix to Status, which had the identical unprotected read.
  • Added a test for read()'s index/entry desync recovery: a key listed in the index whose own entry is missing is skipped rather than failing the whole read.

go build, go vet, and go test -race -count=1 ./internal/oauth/... ./internal/keyring/... ./internal/credstore/... are all clean.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Bound the key index as well as each token entry
    internal/oauth/store.go:560
    The new oauth-tokens-index is still one base64-encoded value written through Keyring.Set, and therefore through macOS security -i's 4095-byte command-line cap. ValidateKey permits 137-byte provider keys; an index containing 22 such keys serializes to 3,081 bytes and base64-expands to 4,108 bytes before the security command framing, so the index write fails even when every token is tiny. The token entry has already been written, but it is not indexed and cannot be loaded. Use a bounded/chunked enumeration format (or another bounded scheme) and add a cap-aware regression test.

  • [P1] Make the multi-entry update recoverable instead of leaving invisible credentials behind
    internal/oauth/store.go:549
    A write now has several externally visible steps with no durable recovery state. If the process dies after a per-token Set but before the index write, the new credential is unindexed forever; if it dies or Delete fails after line 564 publishes a reduced index, a logged-out credential remains in the OS keychain forever because future cleanup only consults that reduced index. An index-write failure can also return an error after replacing an already-indexed token. This is especially harmful for refresh tokens: the caller can be told login/logout failed while the secret has already changed or remains resident but is unreachable through Zero. Add transactional/recovery metadata (or order the operations with a recoverable invariant) and failure-injection coverage for every interruption boundary.

  • [P1] Keep the lock valid for the longer multi-key keyring operation
    internal/oauth/store.go:609
    The split format holds oauth-keyring.lockfile while it runs one external keyring command per entry, but acquireFileLock reclaims any lock older than 30 seconds and never refreshes its mtime. Each command may legitimately take up to 10 seconds, so a normal read or write with only a few slow entries can exceed 30 seconds; another process then reclaims the live lock and resumes a concurrent read-modify-write, allowing the token-loss race this lock is intended to prevent. Refresh/lease the lock during the operation or use a stale timeout that safely covers the bounded keyring work.

  • [P1] Preserve tokens when old and new binaries run during an upgrade or downgrade
    internal/oauth/store.go:488
    Once a new binary creates the index it ignores the legacy combined entry, while an older running binary continues to read and write only that legacy entry. After migration, an old process can save token C to the legacy blob; the next new-process save reads only the index, rewrites it without C, and deletes the legacy blob, silently losing C. The shared lock serializes the two processes but cannot reconcile the two schemas. Provide a compatibility/dual-write transition or otherwise detect and merge legacy writes before removing the legacy entry.

  • [P2] Do not make file-backed reads fail behind a crashed writer's fresh lock
    internal/oauth/store.go:279
    Load and Status now unconditionally acquire the blob lock, including for the file backend. File writes are atomic renames and reads were intentionally lock-free; after a writer crash leaves its lock file, the new read waits only five seconds and errors while the stale-lock threshold remains 30 seconds. That turns a recoverable process crash into roughly 30 seconds of OAuth read failures even though the last complete token file is readable. Restrict the read-side lock to the multi-get keyring backend, or adjust the file-lock recovery behavior so reads retain the former crash tolerance.

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local review: built and ran go test ./internal/oauth on darwin/arm64; all pass. One real correctness concern worth confirming.

Comment thread internal/oauth/store.go Outdated
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed 0b616f7 for jatmn's five findings and gnanam1990's lock-path comment.

  • Bounded index: the key index is now chunked. Continuation entries (oauth-tokens-index-1, -2, ...) hold overflow keys and are written before the header that references them, so no single index entry can exceed the macOS security -i line cap no matter how many providers are logged in. The old single-array format is still read transparently. TestStoreKeyringIndexStaysUnderEntryLimit saves 40 near-maximum-length keys, asserts every keyring value stays under the cap with margin, that the index actually chunked, and that shrinking removes stale chunks.
  • Recoverable updates: write() now publishes the union of the prior and new key sets first, writes token entries second, deletes removed entries while the index still lists them, and shrinks the index last. The invariant is that any token entry existing in the keyring at any instant is listed in the published index, so an interrupted login or logout can never strand an invisible credential; read() already skips over-listed keys and the next write reconciles. TestStoreKeyringWriteInterruptionsLeaveNoInvisibleTokens and its Delete counterpart inject a failure at every mutating operation in turn and check that invariant plus full reconciliation at each boundary.
  • Lock lease: withLock refreshes the lock file's mtime every 10 seconds while the multi-command operation runs, so the 30-second stale threshold can only expire for a genuinely crashed holder. TestStoreKeyringWithLockRefreshesLease covers it with a shortened interval.
  • Mixed versions: a legacy combined entry that exists even though the index has been published was recreated by an old binary running alongside; its keys unseen by the indexed schema are merged into the state before the legacy entry is deleted, so the old binary's freshly saved token survives the next new-binary write. Keys the index already listed are not merged, so a deliberate delete is not resurrected. An old binary's in-place update to an already-indexed token is the one case that still loses to the next new-binary write; full dual-writing of the combined blob would reintroduce the very line-cap overflow this PR removes, so that narrow window is documented instead. TestStoreKeyringMergesFreshLegacyWriteFromOldBinary covers the save case.
  • Read-side lock scoping: the blob interface gained withReadLock. The keyring backend routes it through the same cross-process lock as writes (its read is a multi-Get pass), while the file backend's is deliberately lock-free since its writes are atomic renames, restoring the old crash tolerance. TestStoreFileLoadToleratesCrashedWriterLock plants a fresh never-released lock file and asserts Load/Status succeed immediately.
  • gnanam1990's P2: when ResolveStorePath fails, the lock path now falls back to the OS temp directory instead of disabling cross-process exclusion, so withLock is never a silent no-op for the keyring backend.

go build, go vet, and go test ./internal/oauth pass locally.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/oauth/store.go (1)

212-222: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Prefer a per-user fallback before /tmp.
acquireFileLock already uses O_EXCL and 0600, so symlink-following isn’t the issue, but os.TempDir() still gives a fixed lock path in a shared directory. On multi-user hosts another local user can pre-create or hold that file and make keyring saves time out. os.UserCacheDir() would avoid the shared-path DoS; add a test for the fallback branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 212 - 222, Update the fallback
lock-path logic in the Store construction to prefer a per-user directory from
os.UserCacheDir(), creating or selecting an appropriate cache location before
falling back to os.TempDir() only if the user cache directory cannot be
resolved. Preserve the resolved store-path behavior, and add coverage for the
fallback branch verifying the per-user lock location.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/oauth/store_keyring_test.go`:
- Around line 570-573: Extend the crashed-writer lock test around Status to
verify that Status remains lock-free, rather than merely accepting a successful
call after stale-lock expiry. Add a bounded timing or immediate-return assertion
for s.Status("") while preserving the existing error and single-entry checks;
keep Load’s existing timing assertion unchanged.

---

Outside diff comments:
In `@internal/oauth/store.go`:
- Around line 212-222: Update the fallback lock-path logic in the Store
construction to prefer a per-user directory from os.UserCacheDir(), creating or
selecting an appropriate cache location before falling back to os.TempDir() only
if the user cache directory cannot be resolved. Preserve the resolved store-path
behavior, and add coverage for the fallback branch verifying the per-user lock
location.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6bf70feb-0af6-48ed-a808-dbcac2b2f97f

📥 Commits

Reviewing files that changed from the base of the PR and between 8eac9aa and 0b616f7.

📒 Files selected for processing (2)
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go

Comment thread internal/oauth/store_keyring_test.go
Vasanthdev2004
Vasanthdev2004 previously approved these changes Jul 16, 2026

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean approve from me this latest commit addresses the earlier collaborator P1s (chunked index, recoverable multi-step write, lock-lease refresh, mixed-version legacy merge, lock-free file reads), and the interruption-boundary tests cover every write/delete step; build, vet, gofmt, and the oauth suite pass. Two minor things worth a glance, neither blocking: when ResolveStorePath fails the keyring lock falls back to a shared os.TempDir() file, which on a shared /tmp multi-user host could cross users (a per-user cache dir would be safer); and in the mixed-version window a token an old binary saves to the legacy entry stays invisible to new-binary Load until the next new-binary Save (the write path merges it, so it's not lost, just temporarily unreadable).

@euxaristia
euxaristia requested a review from jatmn July 16, 2026 15:21

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Keep the legacy blob readable until a migration has copied every entry
    internal/oauth/store.go:620
    On a legacy-only installation, Save first publishes the indexed header and only then writes the per-token entries. If the process dies or kr.Set fails after that header write, read sees an index and stops consulting the still-intact legacy blob, so the old credentials disappear. A later save treats the incomplete indexed view as authoritative, removes the missing keys, and deletes the legacy blob, making the logout permanent. The interruption tests seed an already-indexed store and do not cover this legacy-to-indexed boundary. Keep a committed migration marker/legacy fallback until all entries are present, and add a failure-at-each-step migration test.

  • [P1] Do not discard a same-key refresh made by a concurrently running old binary
    internal/oauth/store.go:591
    Once the new index exists, an old binary can still refresh provider:alpha in the legacy combined entry. The next new-binary save reads the stale indexed alpha, skips the newer legacy value solely because prior[alpha] is true, rewrites the stale value, and deletes the legacy entry. This silently loses a successful refresh and leads to later authentication failures; the added mixed-version test covers only a newly introduced key, not an update to an already-indexed one. Reconcile same-key legacy updates (or retain/dual-write the legacy data for the compatibility window) rather than treating their presence as a deliberate delete.

  • [P2] Refresh lock leases with wall-clock time, not the injectable token-expiry clock
    internal/oauth/store.go:811
    StoreOptions.Now is a public option and may be a fixed/stale clock. After the first refresh, this sets the live lock mtime to that old value, while acquireFileLock judges staleness with real time.Since; another process can immediately reclaim the live lock and re-enter the keyring read-modify-write path concurrently. That revives the credential-loss race the lease is meant to prevent. Use time.Now() for the filesystem lease (or make both sides use the same clock) and cover a fixed custom clock.

  • [P2] Bound the advertised index chunk count before issuing keyring reads
    internal/oauth/store.go:703
    The chunked-index parser accepts any integer in header.Chunks and performs one OS-keyring lookup for every value before returning. A corrupt index such as {"v":1,"chunks":1000000000,"keys":[]} makes every Load, Status, Save, and Delete loop through up to a billion lookups while holding the store lock; a real lookup can itself wait ten seconds. Reject unsupported headers and impose a sane maximum before the loop so damaged keyring state fails promptly instead of wedging OAuth operations.

  • [P2] Do not put a per-user keyring lock at a predictable shared-temp path
    internal/oauth/store.go:218
    When ResolveStorePath fails, every user falls back to the same ${TMPDIR}/zero-oauth-keyring.lockfile. On a multi-user host another account can pre-create or keep refreshing that path; the victim then times out acquiring the lock for Load, Status, Save, and Delete despite using a separate OS keychain. The 0600 mode only protects the file after its creator wins. Prefer a user-private cache/state directory for the fallback and add coverage for this path.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/oauth/store_keyring_test.go`:
- Around line 774-781: Strengthen the unsupported-version test around
blob.readKeyIndex by resetting the keyring get-call tracker before invoking it
and asserting exactly one lookup occurred afterward. This must verify that only
the index header is read and no advertised chunk is fetched before rejecting
version 2.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ae205459-3f0d-4de9-ab6d-908745acb243

📥 Commits

Reviewing files that changed from the base of the PR and between 0b616f7 and 7ddca09.

📒 Files selected for processing (2)
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/oauth/store.go

Comment thread internal/oauth/store_keyring_test.go
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed fixes for the review findings:

  • Lock lease renewal now uses wall-clock time unconditionally, instead of the injectable store clock, so a fixed/backdated clock can no longer let another process reclaim a live lock mid-operation.
  • The keyring index now rejects unsupported versions and out-of-range chunk counts before issuing any keyring reads, so a corrupted index can no longer force unbounded lookups.
  • The fallback lock (used when the store path can't be resolved) now lives in a per-user location instead of a shared, predictable temp path.
  • Also hardened legacy-to-indexed migration: reads now recover tokens from the legacy blob if a migration was interrupted, and writes reconcile a same-key refresh made concurrently by an older binary instead of discarding it.

@euxaristia
euxaristia force-pushed the fix/keyring-oauth-per-provider-entries branch from 7ddca09 to 11d038d Compare July 17, 2026 05:32
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 17, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Preserve mixed-version writes on the read path
    internal/oauth/store.go:527
    Once the index exists, read consults the legacy entry only for keys already named by the index. An old binary that is still running can therefore write a new provider:carol login to the legacy blob, but a new binary's Load, Status, and FirstStored report it absent until an unrelated new-binary Save happens. The same one-way merge cannot observe an old-binary logout of an indexed key, so that credential remains usable. This is an explicitly supported upgrade window; retain/reconcile the legacy state on reads (with a protocol that can distinguish stale state from writes) until old writers cannot exist.

  • [P1] Do not discard a refresh whose response has no expiry
    internal/oauth/store.go:612
    legacyIsFresher accepts a legacy update only when both expirations are non-zero and the legacy value is later. PostToken intentionally leaves ExpiresAt zero when an OAuth response omits the optional expires_in, so an old binary can write a newly rotated access/refresh token that the next new-binary save treats as stale. It then overwrites the fresh value and deletes the legacy entry, producing authentication failures. Use a migration/write protocol that preserves such updates rather than using expiry as the sole version signal.

  • [P1] Do not ignore failure to remove the legacy credential blob
    internal/oauth/store.go:727
    A Delete(alpha) can remove alpha from the indexed store and return success even when deletion of a leftover legacy blob fails. On a later save, alpha is no longer in prior, so the stale legacy value is classified as a fresh old-binary login and is written back. The user has successfully logged out according to the API but silently becomes logged in again; propagate/handle that failure or record reconciliation state that prevents resurrection.

  • [P1] Bound a single token entry before sending it to the macOS keyring
    internal/oauth/store.go:703
    Splitting the blob does not ensure that one complete JSON Token, after base64 expansion and security -i framing, fits macOS's 4095-byte command-line limit. Large but valid JWT access/ID tokens or refresh tokens still make KeyringClient.Set fail, so the login path retains the very failure this PR claims to eliminate for that provider. Enforce a clear per-token limit with an actionable fallback, or chunk the token representation; the five-provider test only covers smaller synthetic values.

  • [P2] Reject an index that the reader cannot reopen
    internal/oauth/store.go:826
    writeKeyIndex will publish any number of chunks, while readKeyIndex rejects headers above maxKeyringIndexChunks (128). Enough valid provider/MCP keys therefore lets Save succeed and persist a 129-chunk header, after which every Load, Status, Save, and Delete fails before it can recover the store. Enforce the same maximum before publishing (or make the on-disk format readable beyond it).

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holding at changes requested, but this is close. The latest push handles both of my earlier notes: the per-user lock fallback (UserCacheDir, uid-scoped temp as last resort) is exactly what I had in mind, and the read-side legacy recovery plus the wall-clock lease refresh are good hardening.

Out of jatmn's latest round, one finding is a real bug I want fixed before merge: write() throws away the error from the final legacy-blob delete (store.go:727). After a logout, if that delete fails, Delete still reports success while the secret stays resident in the keychain — and worse, the next Save sees the leftover legacy key with prior[key] now false and writes it back as a "fresh old-binary login". A logged-out user silently becomes logged in again, and even a retried logout hits the same merge. Propagate that error and add a failure-injection case at that boundary like the ones you already built. While you're in there, enforce maxKeyringIndexChunks on the write side too — writeKeyIndex will happily publish a header that readKeyIndex then refuses, and the guard is a couple of lines.

The rest I'm deliberately not gating on: the read-path visibility gap and the zero-expiry freshness heuristic are confined to the transient old-binary window and documented as best-effort, which I'm fine with — I don't want more protocol thrown at that window. And a single token blowing the 4095-byte cap predates this PR (the combined blob hit that wall sooner), so a clearer error there can be a follow-up. Fix the two items above and I'll approve.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/oauth/store_keyring_test.go`:
- Around line 471-475: Strengthen the legacy merge regression assertions: at
internal/oauth/store_keyring_test.go lines 471-475, load carol and verify its
access and refresh tokens match the expected values, not just presence; at lines
693-699, also assert the loaded credential’s ExpiresAt.Equal(fresh). Preserve
the existing checks for the other legacy values.
- Around line 408-412: Update the fault-injection tests in
internal/oauth/store_keyring_test.go at lines 408-412 and 627-635: capture the
Delete error in the first test and assert that a fired injection is returned;
capture the Save error in the second, assert it is returned, and verify seeded
tokens remain readable before reconciliation. Use the existing test helpers and
symbols, keeping reconciliation after these assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d6ae3098-ce1c-449e-9a50-629517bc5d25

📥 Commits

Reviewing files that changed from the base of the PR and between 7ddca09 and 496070e.

📒 Files selected for processing (2)
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/oauth/store.go

Comment thread internal/oauth/store_keyring_test.go
Comment thread internal/oauth/store_keyring_test.go
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed 496070e for the two gating items: the final legacy-blob delete's failure now propagates (a logout can no longer report success with the secret resident), and it runs while the union index still lists removed keys — so a retried logout reconciles instead of the stale legacy key being resurrected as a "fresh old-binary login" on the next save. writeKeyIndex now enforces maxKeyringIndexChunks before publishing anything, so Save can't persist a header readKeyIndex refuses. Added a failure-injection test at the legacy-delete boundary (including the no-resurrection-after-retry assertion) and a write-side cap test; the existing write-interruption test now asserts every mutating boundary surfaces its injected failure.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Bound the decoded index keys before iterating them
    internal/oauth/store.go:785
    The new guard only limits header.Chunks; it accepts an arbitrarily large header.Keys array (and the supported legacy bare-array format), then read invokes kr.Get once per element while holding the cross-process lock. A corrupt index containing thousands of duplicate or invalid keys therefore serializes thousands of OS-keyring calls—each can wait up to ten seconds—and wedges Load, Status, Save, and Delete. Cap the raw/decoded index and total unique valid keys before the fan-out, including every chunk and the legacy-array branch.

  • [P1] Use a wall-clock deadline when taking the keyring read lock
    internal/oauth/store.go:933
    withReadLock now calls withLock, which passes StoreOptions.Now to acquireFileLock. That helper derives both its deadline and timeout check from the supplied clock; with a legitimate fixed test/embedded clock, now().After(now().Add(5s)) can never become true. A fresh peer or orphan lock then makes keyring Load and Status retry forever instead of returning a lock-timeout error. Use wall/monotonic time for the acquisition deadline, as the lease refresher already does.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 19, 2026
@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed 7b22465.

Fixed:

  • acquireFileLock computed its timeout deadline using the injectable clock, so a fixed clock could make lock contention retry forever instead of timing out. Now computes the deadline against wall-clock time, keeping the injectable clock for token generation only.
  • readKeyIndex had no cap on keys per chunk, so a corrupted index could drive an unbounded keyring lookup fan-out while holding the store lock. Added a cap on both the legacy bare-array and chunked-header decode paths.
  • Strengthened five existing tests that were marked addressed but weren't fully (missing error captures, missing value assertions, missing reset+assertion pairs).

Not fixing, per the sign-off already on this thread:

  • The transient read-path visibility gap for an old binary's concurrent write, and the zero-expiry freshness heuristic: confined to the old-binary compatibility window and fine as documented best-effort.
  • A single token exceeding the 4095-byte macOS security -i cap: pre-existing, predates this PR, follow-up item not a blocker here.

-race wasn't runnable in my environment (no gcc for cgo), worth running before merge.

@euxaristia

Copy link
Copy Markdown
Contributor Author

Pushed ea5b965.

Hardening on top of 7b22465 / 496070e for the remaining write/read symmetry gap around the key-count cap:

  • writeKeyIndex now refuses a key list over maxKeyringIndexKeys before publishing anything (same class of bug as the write-side maxKeyringIndexChunks guard). Short keys can still fit under the chunk-count cap while exceeding the reader key cap; without this check Save could persist an index every later Load/Status/Save/Delete would reject.
  • Extended the oversized key-list reader test: header-only and legacy bare-array paths now assert header lookup only, and a multi-chunk accumulation case (small header + oversized chunk-1) is covered.
  • Added TestStoreKeyringWriteIndexRejectsOverCapKeys for the write-side path.

Human findings already on this PR, with the commits that closed them:

  • jatmn (on 496070e): wall-clock lock acquisition deadline + bound decoded index keys before fan-out -> 7b22465; write-side key-cap symmetry above -> ea5b965
  • Vasanthdev2004 (on 11d038d): propagate legacy-blob delete failure on logout + write-side maxKeyringIndexChunks -> 496070e

Still not fixing, per the existing sign-off on this PR:

  • Transient read-path visibility gap for an old binary's concurrent write, and the zero-expiry freshness heuristic: confined to the old-binary compatibility window and documented best-effort.
  • A single token exceeding the 4095-byte macOS security -i cap: pre-existing, predates this PR, follow-up item.

go fmt, go vet, golangci-lint (unused/ineffassign/staticcheck), govulncheck, and go test ./internal/oauth/... ./internal/keyring/... ./internal/credstore/... are clean. -race needs gcc/cgo and was not runnable in this environment.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 19, 2026
@euxaristia

Copy link
Copy Markdown
Contributor Author

@Vasanthdev2004 Ready for re-review on head ea5b965.

Both gating items are in:

  • write() now propagates the legacy-blob delete error (store.go), with a failure-injection case at that boundary: TestStoreKeyringLogoutSurfacesLegacyBlobDeleteFailure.
  • The index caps are enforced on the write side too: writeKeyIndex refuses over-cap chunk counts and key counts before publishing anything (TestStoreKeyringWriteIndexRejectsOverCapChunks, TestStoreKeyringWriteIndexRejectsOverCapKeys).

jatmn's two P1s from the same round (bounding decoded index keys in readKeyIndex and the wall-clock lock deadline) are also fixed, with tests. CI is green on all jobs.

@euxaristia
euxaristia force-pushed the fix/keyring-oauth-per-provider-entries branch from ea5b965 to 98e1664 Compare July 20, 2026 11:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
internal/oauth/store.go (1)

281-292: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not derive the shared lock identity from HOME/USERPROFILE.

os.UserHomeDir() uses those environment values, so two processes for one OS user with different home overrides can still acquire different locks and race the shared keyring index. This repeats the previously reported issue despite being marked addressed. Use an OS-account-derived identity and add a regression varying both home variables.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 281 - 292, The keyringLockPath function
still derives the shared lock location from HOME/USERPROFILE via os.UserHomeDir,
allowing one OS account to use different locks. Replace that identity source
with an OS-account-derived stable value, retain the existing temp fallback
semantics, and add a regression test that varies both home environment variables
while verifying the lock identity remains the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@internal/oauth/store.go`:
- Around line 281-292: The keyringLockPath function still derives the shared
lock location from HOME/USERPROFILE via os.UserHomeDir, allowing one OS account
to use different locks. Replace that identity source with an OS-account-derived
stable value, retain the existing temp fallback semantics, and add a regression
test that varies both home environment variables while verifying the lock
identity remains the same.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a731bf4-3939-4b5f-ad2b-ee5906d2984e

📥 Commits

Reviewing files that changed from the base of the PR and between d5f0dfb and 61b8406.

📒 Files selected for processing (2)
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/oauth/store_keyring_test.go (1)

35-38: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

These tests now write lock files into the real $HOME, and the comment is stale. keyringLockPath deliberately ignores the env map and uses os.UserHomeDir(), so t.Setenv("XDG_CONFIG_HOME", ...) no longer redirects the keyring lock — every keyring test in this file acquires ~/.cache/zero/oauth-keyring-zero-oauth-tokens-index.lockfile on the machine running the suite. Sequential package execution hides it today, but two concurrently running test binaries (or a leftover lock from a killed run) will hit the 5s acquisition timeout, and it pollutes a contributor's home directory. Setting HOME per test keeps it hermetic and also fixes the comment.

🧪 Proposed fix (apply to each keyring test that sets XDG_CONFIG_HOME)
-	// Keep the cross-process keyring lock file inside a temp config dir.
-	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+	// Keep both the legacy (config-anchored) and keyring (home-anchored)
+	// lock files inside temp dirs so the suite never touches the real $HOME.
+	t.Setenv("HOME", t.TempDir())
+	t.Setenv("USERPROFILE", t.TempDir())
+	t.Setenv("XDG_CONFIG_HOME", t.TempDir())

A shared newKeyringTestStore(t)/isolateLockPaths(t) helper would avoid repeating this in ~15 tests. Note TestKeyringLockPathIsPerUser intentionally asserts against the real os.UserHomeDir(), so it should compute the expectation after the override rather than being skipped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store_keyring_test.go` around lines 35 - 38, Update all
keyring tests that currently set only XDG_CONFIG_HOME to override HOME with the
test-specific temporary directory, preferably through a shared helper such as
newKeyringTestStore or isolateLockPaths. Ensure keyringLockPath resolves lock
files within the temporary home, update the stale comment, and adjust
TestKeyringLockPathIsPerUser to compute its expected path after the HOME
override.
🧹 Nitpick comments (4)
internal/oauth/flow_test.go (1)

303-316: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the configured-scope fallback branch.

This regression test verifies non-empty current.Scopes, but not the new len(current.Scopes) == 0 path that falls back to cfg.Scopes. Add a case with empty current scopes and assert that the returned token preserves fallback-scope when the response omits scope.

Suggested test case
+func TestRefreshFallsBackToConfiguredScopesWhenCurrentScopesAreEmpty(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		_, _ = w.Write([]byte(`{"access_token":"new-at","expires_in":3600}`))
+	}))
+	defer server.Close()
+
+	cfg := Config{ClientID: "c", TokenEndpoint: server.URL, Scopes: []string{"fallback-scope"}}
+	tok, err := Refresh(context.Background(), server.Client(), cfg, Token{RefreshToken: "keep-me"}, nil)
+	if err != nil {
+		t.Fatalf("Refresh: %v", err)
+	}
+	if len(tok.Scopes) != 1 || tok.Scopes[0] != "fallback-scope" {
+		t.Fatalf("refresh should fall back to configured scopes, got %v", tok.Scopes)
+	}
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/flow_test.go` around lines 303 - 316, Extend
TestRefreshPreservesScopesWhenOmitted with a case using a token whose Scopes is
empty, while keeping cfg.Scopes set to fallback-scope and the response without
scope. Assert Refresh returns fallback-scope in the token’s Scopes, covering the
configured-scope fallback branch alongside the existing current-scope case.

Source: Coding guidelines

internal/oauth/store.go (3)

896-899: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Both logging and returning the same message double-reports. Every caller already wraps/propagates this error; log.Printf from a library function also writes to whatever stderr the CLI owns. Consider dropping the log and letting the caller decide.

♻️ Proposed cleanup
 func errKeyringIndexTooManyKeys(count int) error {
-	log.Printf("warning: oauth: keyring token index lists %d keys, over the %d-key cap", count, maxKeyringIndexKeys)
 	return fmt.Errorf("oauth: keyring token index lists %d keys, over the %d-key cap", count, maxKeyringIndexKeys)
 }

(and drop the now-unused log import if nothing else uses it)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 896 - 899, Remove the log.Printf call
from errKeyringIndexTooManyKeys and retain only the formatted error return,
allowing callers to control reporting. Remove the log import if no other code in
the file uses it.

1082-1132: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

A panic inside fn strands the lock files and the lease goroutine. close(stop) and the unlock loop only run on normal return, so a panic anywhere in the keyring read/write path (a nil map, a bad type assertion) leaves a lock file behind that other processes must wait ~30s to reclaim, plus a live ticker goroutine. defer makes this unconditional at no cost.

🛡️ Proposed fix
 	if len(held) == 0 {
 		return fn()
 	}
+	defer func() {
+		for i := len(unlocks) - 1; i >= 0; i-- {
+			unlocks[i]()
+		}
+	}()
 	stop := make(chan struct{})
 	done := make(chan struct{})
 	go func() {
@@
 	}()
+	defer func() {
+		close(stop)
+		<-done
+	}()
-	err := fn()
-	close(stop)
-	<-done
-	for i := len(unlocks) - 1; i >= 0; i-- {
-		unlocks[i]()
-	}
-	return err
+	return fn()
 }

Note the deferred order: the lease-stop defer must run before the unlock defer, which the ordering above gives you.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 1082 - 1132, Update withLeasedLocks to
defer cleanup immediately after starting the lease goroutine so it runs during
both normal returns and panics from fn. Ensure the defer order stops and joins
the lease goroutine before releasing locks, while preserving reverse-order
unlock behavior and returning fn’s error normally.

283-292: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Comment misdescribes resolveHomeDir. It honors only HOME/USERPROFILE (lines 179-188), not ZERO_OAUTH_TOKENS_PATH/XDG_CONFIG_HOME. The doc block above (lines 266-280) also says the path is anchored "see resolveHomeDir" while the body deliberately uses os.UserHomeDir. Worth aligning both so the next reader doesn't "simplify" this back to resolveHomeDir.

✏️ Suggested comment fix
-	// Use OS.UserHomeDir (not resolveHomeDir) for a stable per-OS-user
-	// identity. resolveHomeDir honors ZERO_OAUTH_TOKENS_PATH and
-	// XDG_CONFIG_HOME overrides; using those for the lock path means
-	// two processes for the same keychain user with different HOME
-	// values take different locks, both read-modify-write the shared
-	// keyring index, and one saved token can be left unindexed.
+	// Use os.UserHomeDir (not resolveHomeDir) for a stable per-OS-user
+	// identity: resolveHomeDir honors the caller-supplied HOME/USERPROFILE
+	// env map, so two processes for the same keychain user with different
+	// HOME values would take different locks, both read-modify-write the
+	// shared keyring index, and one saved token could be left unindexed.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 283 - 292, Correct the comments above
the lock-path logic to accurately describe resolveHomeDir as honoring
HOME/USERPROFILE, not ZERO_OAUTH_TOKENS_PATH or XDG_CONFIG_HOME. Update the doc
block and inline comment around the os.UserHomeDir-based path to explain that
the lock is anchored to the OS user’s home directory and must remain distinct
from resolveHomeDir.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/oauth/store_keyring_test.go`:
- Around line 35-38: Update all keyring tests that currently set only
XDG_CONFIG_HOME to override HOME with the test-specific temporary directory,
preferably through a shared helper such as newKeyringTestStore or
isolateLockPaths. Ensure keyringLockPath resolves lock files within the
temporary home, update the stale comment, and adjust
TestKeyringLockPathIsPerUser to compute its expected path after the HOME
override.

---

Nitpick comments:
In `@internal/oauth/flow_test.go`:
- Around line 303-316: Extend TestRefreshPreservesScopesWhenOmitted with a case
using a token whose Scopes is empty, while keeping cfg.Scopes set to
fallback-scope and the response without scope. Assert Refresh returns
fallback-scope in the token’s Scopes, covering the configured-scope fallback
branch alongside the existing current-scope case.

In `@internal/oauth/store.go`:
- Around line 896-899: Remove the log.Printf call from
errKeyringIndexTooManyKeys and retain only the formatted error return, allowing
callers to control reporting. Remove the log import if no other code in the file
uses it.
- Around line 1082-1132: Update withLeasedLocks to defer cleanup immediately
after starting the lease goroutine so it runs during both normal returns and
panics from fn. Ensure the defer order stops and joins the lease goroutine
before releasing locks, while preserving reverse-order unlock behavior and
returning fn’s error normally.
- Around line 283-292: Correct the comments above the lock-path logic to
accurately describe resolveHomeDir as honoring HOME/USERPROFILE, not
ZERO_OAUTH_TOKENS_PATH or XDG_CONFIG_HOME. Update the doc block and inline
comment around the os.UserHomeDir-based path to explain that the lock is
anchored to the OS user’s home directory and must remain distinct from
resolveHomeDir.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 623e9dc2-db0f-4014-bec5-9d222a636152

📥 Commits

Reviewing files that changed from the base of the PR and between 097c265 and 61b8406.

📒 Files selected for processing (5)
  • internal/oauth/flow.go
  • internal/oauth/flow_test.go
  • internal/oauth/lock.go
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 30, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rechecked the current head against the prior review. The chunked-index cap, dedupe-before-fan-out, env-aware legacy lock path, lock-free file reads, and interruption tests look addressed. The mixed-version and logout gaps below are still reproducible in the code on this head.

Findings

  • [P1] Keep the index lock tied to a stable per-keychain identity, not per-process $HOME
    internal/oauth/store.go:281
    keyringLockPath ignores its env argument and anchors on os.UserHomeDir(), which on Unix follows each process’s $HOME. Two zero processes for the same OS keychain user but with different home overrides (sandbox wrappers, CI harnesses, launcher profiles) therefore take different lock files under $HOME/.cache/zero/… while still read-modifying-writing the fixed zero/oauth-tokens-index entry. That revives the cross-process index race this PR added locking to prevent: one process’s Save can be dropped from the published index while its per-key entry still exists, or vice versa. This is unlikely in a single desktop shell with one stable $HOME, but it is real for the sandbox/CI cases this lock design already discusses. Please derive the lock from a stable per-OS-user identity that does not vary with caller-controlled HOME, and add a regression test with two stores whose processes use different $HOME values but share one injected keyring client.

  • [P1] Serve the fresher legacy credential during the compatibility window on read paths
    internal/oauth/store.go:624
    Once the index exists, read() always keeps the indexed per-key entry when kr.Get(service, key) succeeds. The trailing legacy merge only adds keys that are absent from the assembled map; it never runs legacyIsFresher against overlapping keys. During mixed-version rollout an old binary refreshes a provider only in the legacy combined entry, so Load, Status, and GetFresh keep returning the stale indexed access/refresh token until some unrelated Save/Delete triggers write() reconciliation. That can surface expired credentials, skip needed refresh, or fail with a rotated refresh token before any write occurs. TestStoreKeyringMergesFreshLegacyRefreshOfIndexedKey only covers reconciliation on Save, not Load alone. Please apply the same verified freshness rule on read (or keep both representations coherently readable until migration completes), and add a test that seeds a fresher legacy refresh for an already-indexed key then asserts Load returns it without a subsequent Save.

  • [P1] Do not discard valid refreshes that omit expires_in
    internal/oauth/store.go:728
    OAuth refresh responses may omit expires_in, and Refresh does not carry the previous expiry into its base token when building the refreshed result. During mixed-version reconciliation, an old-binary legacy refresh can therefore land with ExpiresAt zero while the indexed copy still carries a non-zero expiry. legacyIsFresher then returns false without comparing access/refresh material, so write() keeps the stale indexed credential and deletes the legacy blob in step 4. That loses a valid (and potentially rotation-required) refresh during the supported mixed-version window. Please reconcile overlapping keys using token material and explicit expiry semantics consistent with Token.Expired (zero expiry means “unknown”, not “older”), not expiry ordering alone.

  • [P1] Do not resurrect a token the caller just logged out
    internal/oauth/store.go:789
    If an old binary adds beta only to the legacy blob after the new index already contains alpha, read() exposes beta, so Delete(beta) removes it from the in-memory state. During write() reconciliation, beta is not in prior (it was never indexed), so the branch at lines 799–802 classifies that same legacy value as a fresh old-binary login and puts it back; the operation can return removed=true while beta is written into the indexed store and the legacy blob is deleted. Please preserve deletion intent through reconciliation (for example by tracking keys removed in the current operation, or by refusing to merge legacy keys that were present in the read state but absent from the write state) so logout cannot leave the credential usable.

  • [P1] Do not re-expose logged-out credentials through the read-side legacy merge
    internal/oauth/store.go:668
    write() refuses to resurrect deliberately removed keys when prior[key] is set, but read() unconditionally merges every legacy token whose key is absent from the indexed map. If logout’s multi-step write() deletes the per-key entry and shrinks the in-memory state but fails while deleting the legacy blob (step 4), or if a still-running pre-PR binary repopulates the legacy combined entry after a successful logout, the next Load/Status rebuilds the credential from legacy with no prior guard. A later unrelated Save calls readState() first and can re-persist that credential via write() step 2. Please mirror the logout-preservation rule on the read path (or block legacy consultation under the same lock contract as mixed-version writes), and cover both “failed legacy delete after entry delete” and “old binary rewrites legacy after shrink” with tests that assert Load stays empty without retrying Delete.

  • [P3] Narrow the per-token size claim or add an explicit single-entry bound
    internal/oauth/store.go:831
    This PR correctly fixes the reported regression: many providers in one combined blob exceeded the macOS security -i line cap. Per-key storage removes that aggregation failure. Each kr.Set still ships one marshaled token as a single security -i line, and internal/keyring rejects lines longer than 4095 bytes including service/account framing. A single login with very large JWT payloads could still fail on macOS even with one provider logged in. That is a separate edge case from the multi-provider bug, and the regression tests only cap encoded secret length in fakeKR (~3000 bytes), not the full command line. Please either tighten the PR/release wording to “bounded per provider count” or add a pre-write size check with a clear error for oversized single-token payloads.

  • [P3] Consider whether the five-second lock acquisition timeout is too tight under slow keyring I/O
    internal/oauth/lock.go:15
    fileLockTimeout is five seconds, while each keyring subprocess can run up to ten seconds and a contested Load/Save under withLeasedLocks may wait behind a holder doing several operations. A spurious oauth: timed out acquiring token lock is possible under heavy contention or a slow keychain, though CI smoke on this head passed and typical installs have few providers. This looks latent rather than a main-path regression introduced here. If you keep the current timeout, a short comment documenting the assumed upper bound on held critical sections would help; otherwise align the waiter timeout with worst-case keyring latency.

…ries

Address requested review changes for keyring per-provider entries:
- Anchor keyring lock path on user.Current() home directory so it stays stable across per-process HOME overrides.
- Serve fresher legacy credentials directly on read paths without requiring a Save.
- Evaluate token material changes in legacyIsFresher when expiries are zero (omitted expires_in).
- Preserve deletion intent through reconciliation so logged-out tokens are never resurrected.
- Add maxKeyringSingleEntryBytes bound check (3800 bytes) before keyring Set.
- Document fileLockTimeout critical section upper bounds.

Refs Gitlawb#668

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Coordinate legacy migration across configured roots
    internal/oauth/store.go:296
    legacyKeyringLockPath selects the lock from the new process's ZERO_OAUTH_TOKENS_PATH/XDG_CONFIG_HOME, while a pre-PR binary selects it from its own environment; both still mutate the fixed zero/oauth-tokens keyring item. If the old process uses root A and the new one uses root B, the new writer can reconcile the legacy blob, the old writer can save a login or rotated refresh under A's lock, and the new writer then deletes that unseen blob under B's lock. This loses a successful credential update during the explicitly supported mixed-version window. The current test only holds the new store's computed legacy lock; preserve the legacy data or use a migration protocol that remains safe when the two processes cannot share a configuration-root lock.

  • [P1] Do not hide legacy-only credentials after publishing the index
    internal/oauth/store.go:614
    Once an index exists, read iterates only its keys and consults the legacy blob only for an overlapping/missing indexed key. An old binary can therefore log into a new provider after migration, leaving (for example) provider:beta only in oauth-tokens; Load, Status, and FirstStored all report it absent until an unrelated new-version write happens. Delete(beta) then returns false and leaves the credential behind, which a later Save merges at line 786. Keep legacy-only keys visible—and make their deletion durable—through the compatibility window rather than making the index authoritative before old writers are gone.

  • [P1] Do not use expiry as the ordering protocol for rotated credentials
    internal/oauth/store.go:719
    legacyIsFresher treats a legacy token with an earlier non-zero expiry as stale even when its access/refresh material has rotated, and treats any material difference as legacy-newer when either expiry is zero. OAuth responses may legitimately omit expires_in (the new Refresh path leaves ExpiresAt zero), and a successful refresh can receive a shorter TTL. Consequently an old legacy copy can overwrite a just-refreshed indexed token, or a fresh old-binary rotation can be discarded, before line 856 deletes the only other copy. The two representations need a monotonic migration/revision rule; expiry is not a write-order signal.

  • [P2] Cap raw index input before deduplicating it
    internal/oauth/store.go:936
    The 512-key limit is applied only after json.Unmarshal has materialized the entire header/chunk array and dedupeValidKeys has preallocated a map and result slice sized to that raw array. A corrupted Linux keyring entry containing millions of copies of one valid key deduplicates to one and passes the cap, but consumes unbounded memory and CPU while every OAuth operation holds the store lock. Apply a byte/raw-element limit before unmarshalling or preallocation; the duplicate regression tests currently exercise exactly the unbounded-input shape but only at a few thousand entries.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Serialize migration with legacy writers using other config roots
    internal/oauth/store.go:296
    legacyKeyringLockPath is calculated only from the new process's ZERO_OAUTH_TOKENS_PATH/XDG_CONFIG_HOME, while a still-running pre-PR binary calculates its sole lock from its own root even though both write the fixed zero/oauth-tokens keyring account. With old process A and new process B using different roots, A can save a login or rotated refresh after B has reconciled the legacy blob but before B deletes it at line 861; B then deletes A's successful update. The compatibility test shares one environment, so it misses this supported mixed-version path. Use a migration protocol that cannot delete an uncoordinated legacy write, or retain the legacy representation until old writers are excluded.

  • [P1] Give mixed-version copies a real ordering rule before merging them
    internal/oauth/store.go:736
    legacyIsFresher treats any different access or refresh string as newer and ignores every other field. An old binary can save a stale whole-blob snapshot while a new binary has already refreshed alpha; both Load and the next unrelated Save replace the newer indexed alpha with that stale legacy value and then delete the only newer copy. Conversely, a refresh that reuses the token strings but extends expires_in is discarded. A migration revision/tombstone protocol is needed here—expiry and token material are not a safe cross-writer ordering signal.

  • [P1] Preserve logout semantics while reconciling the two formats
    internal/oauth/store.go:791
    The migration has no durable deletion signal. If the index contains alpha and an old binary writes legacy-only beta, read exposes beta; Delete(beta) removes it from memory, but this loop sees beta absent from prior, classifies the same legacy value as a fresh login, and writes it back. The call returns removed=true although Load(beta) still succeeds. The opposite direction is also lost: when an old binary deletes indexed alpha from its legacy blob, this loop never sees an alpha entry to reconcile, so new processes continue serving alpha. Preserve deletion intent/tombstones across reconciliation instead of treating an absent key as an unchanged stale copy.

  • [P2] Bound the index payload before decoding it
    internal/oauth/store.go:941
    The new 16,384-element guard runs only after base64 decoding and json.Unmarshal have fully allocated the keyring-controlled header or chunk. A corrupted Linux keyring entry can therefore contain a huge JSON payload (or a small number of enormous strings) and consume memory/CPU on every Load, Status, Save, or Delete while the store lock is held, before the count check rejects it. Cap encoded/decoded bytes before unmarshalling (valid writer output is already bounded to 2700 raw bytes per chunk).

  • [P2] Make lock acquisition cover a healthy keyring pass
    internal/oauth/lock.go:19
    A contender gives up after five seconds, but one internal/keyring Get/Set/Delete is allowed to run for ten seconds and the new read/write path performs one such call per indexed entry while holding this global lock. A healthy slow keychain operation therefore makes concurrent ordinary Load/Status/Save/Delete fail with a timeout; lease refresh only prevents unsafe reclamation. Align the wait timeout with the bounded critical-section latency (or otherwise avoid failing concurrent callers behind a live holder).

Preserve logout intent through legacy reconciliation so a legacy-only
credential is not reclassified as a fresh old-binary login after Delete.
Restore hybrid legacyIsFresher ordering (expiry when both known, else
token material) so zero-expiry refreshes are not discarded, and cover
the remaining mixed-version lock and delete edge cases with regression tests.

Refs Gitlawb#668
@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed remaining keyring migration P1s:

  1. Delete does not resurrect legacy-only keys — omit set through write/reconcile so logout cannot re-merge a legacy-only entry as a "fresh old-binary login".
  2. Zero-expires_in refreshes — hybrid legacyIsFresher: when both expiries are set, later expiry wins; otherwise fall back to access/refresh material.

Earlier tip already had: fan-out cap after dedupe, legacyKeyringLockPath via ResolveStorePath, stable OS-user index lock, fresher-legacy on overlapping reads.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Validate token entries before publishing their index keys
    internal/oauth/store.go:842
    write commits the union index at line 856, but only marshals and checks the new values at lines 861-868. A rejected Save(provider:x, hugeToken) therefore leaves provider:x in the authoritative index without an entry. read deliberately skips that missing entry, so Delete(provider:x) returns false without rewriting the index. After 512 such rejected, unique saves, the next normal save constructs a 513-key union and fails the reader cap before it can repair the index; the keyring store remains unable to save credentials until the user manually edits the keychain. Preflight and size-check the complete desired token set before publishing any index, and make interrupted writes recoverable with explicit operation state (or a verified missing-entry pruning path) so unmaterialized index keys cannot consume permanent capacity. This needs to cover KeyringClient.Set failures too, not only oversized payloads.

  • [P1] Serialize migration with legacy writers using other config roots
    internal/oauth/store.go:296
    The compatibility lock cannot work across process-local config roots: legacyKeyringLockPath derives B's lock from the new process's ZERO_OAUTH_TOKENS_PATH/XDG_CONFIG_HOME, whereas a pre-PR process with root A takes only A's legacy lock. Both nonetheless mutate the fixed zero/oauth-tokens keyring account. New B can read/reconcile the legacy blob, old A can save a login or refresh, and B then deletes that unseen update at line 889. The credential is never indexed and is permanently lost. Since a new binary cannot discover every old process's configured root, adding another new-schema lock is not sufficient. Keep/dual-write the legacy representation for the supported coexistence period, or use a migration protocol that never deletes it until old writers are known to be excluded; add a regression test with two distinct config roots.

  • [P1] Do not use token contents or expiry as a cross-version write order
    internal/oauth/store.go:746
    legacyIsFresher treats a later expiry, or any changed access/refresh string when either expiry is absent, as proof that the legacy copy was written later. Neither property establishes causal order. For example, an old process can leave account A's token expiring tomorrow, then a user explicitly logs in through the new binary as account B with a one-hour token. The reconciliation loop replaces the just-supplied B token at line 825 with A, writes A, and deletes the only B copy. OAuth also permits expires_in to be absent, so different token material alone makes either stale no-expiry copy win. This can silently retain revoked credentials or authenticate as the wrong account. A correct fix needs a durable ordering/deletion protocol (or a coexistence strategy that preserves both formats); expiry and token strings cannot serve as a version vector. At minimum, do not let legacy reconciliation overwrite an explicit current Save without evidence it was written later, and test competing login/refresh writes in both orders.

  • [P2] Bound keyring index bytes before decoding JSON
    internal/oauth/store.go:969
    The raw-key limit is checked only after arbitrary keyring data has been base64-decoded and unmarshaled into []string or keyIndexHeader. A damaged index or chunk can contain a huge payload, or a few huge strings, and consume unbounded memory/CPU before the count guard runs; every OAuth operation reaches this while holding the global lock. The valid writer already limits chunks to 2700 raw bytes, so reject an encoded value whose decoded length exceeds a small header/chunk bound before calling DecodeString or json.Unmarshal, and apply the same bound to every continuation chunk. The current element-count cap is complementary, not a substitute: it does not bound the size of an individual JSON string.

  • [P2] Let lock acquisition cover a healthy keyring operation
    internal/oauth/lock.go:19
    Contenders give up after five seconds, but one OS-keyring command is allowed to take ten seconds and the new multi-entry read/write path performs several commands while holding the lease. A healthy Get that takes six seconds is enough for a concurrent Load, Status, Save, or Delete to fail with timed out acquiring token lock; a multi-provider pass can legitimately be much longer. Lease refresh prevents unsafe stale reclamation but does not help waiting callers. The root problem is a fixed acquisition deadline below the possible critical-section duration, not the stale-lock threshold. Either make waiting cancellation-aware and wait while a holder's lease remains healthy, or redesign the multi-entry read/write around a committed generation/snapshot so readers can retry without waiting for an unbounded pass. Simply raising five seconds to ten seconds is insufficient when the locked operation contains multiple ten-second commands.

Address jatmn review on PR Gitlawb#668: preflight token size before publishing
index keys and prune phantoms so failed Saves cannot brick the store;
dual-write the legacy blob without deleting it (size-capped) so
cross-root old writers cannot permanently lose credentials; stop using
expiry/token material as causal order when reconciling; bound index
payloads before base64/JSON decode; wait while a lock lease stays healthy.

Refs Gitlawb#668
@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed the latest review:

  1. Index before entry validation — marshal/size-check the full desired token set before publishing the index; prune phantom index keys with no live entry. Oversized Save no longer leaves a permanent unmaterialized index key.

  2. Cross-root legacy writers — never delete the legacy combined entry; dual-write reconciled state (size-capped) so old writers on other config roots cannot lose credentials to a premature legacy delete.

  3. No content/expiry write-order — removed legacyIsFresher; indexed/explicit state always wins for the same key; legacy only contributes legacy-only keys.

  4. Index payload bound — reject oversized encoded keyring index values before base64/JSON decode.

  5. Lock wait — wait while the holder lease remains healthy (mtime within stale window), with a 2m absolute ceiling.

Remaining coexistence gaps (same-key concurrent old-binary refresh, dual-write size cap, true causal order) are noted in the commit; full generation/snapshot redesign deferred.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Preserve an old-writer update that lands during legacy reconciliation
    internal/oauth/store.go:770
    The two versions cannot share legacyLockPath when their config roots differ, but they still both read-modify-write the single fixed zero/oauth-tokens account. In the failing order, new B (root B) snapshots the legacy map here, old A (root A) saves a new provider or rotated credential to that account, then B reaches dualWriteLegacy and writes its stale state at line 896. A's credential was never indexed and B has overwritten its only copy, so neither version can discover it. Retaining the legacy entry rather than deleting it does not make a snapshot-then-Set operation safe.

    The root cause is treating two independently writable representations as though a per-config-root lock provides a global serialization or as though the token values can supply write order. Please redesign the coexistence protocol around a durable ordering/ownership rule: for example, keep one representation authoritative and writable until old writers are excluded, or use a persistent generation/tombstone protocol that prevents an unobserved legacy write from being overwritten. Do not try to repair this with another root-specific lock or by comparing expiry/token contents; neither can order these writes. Add a deterministic interleaving test that pauses B after the legacy read, lets A write, then resumes B and proves every credential remains readable.

  • [P1] Keep a logout durable when an uncoordinated old binary rewrites its stale snapshot
    internal/oauth/store.go:664
    A new-version Delete(alpha) removes the indexed entry and rewrites its legacy view without alpha. An old process using another config root can nevertheless finish a read-modify-write that began before the delete and put its stale alpha back into the shared legacy account. read imports every legacy-only key at this loop, so the next Load(alpha) authenticates with the supposedly logged-out token; a later unrelated Save reindexes it through the merge at line 775. omitFromLegacy applies only to the one delete call, and there is no persistent deletion marker visible to that later old-writer snapshot.

    This is the deletion half of the same split-authority protocol problem. A logout cannot be made durable merely by removing a key from whichever representation the current process read: an old snapshot has no way to distinguish a deliberate deletion from a key that it should restore. Address it with the same durable migration state as the write race—e.g. a monotonic generation plus tombstones, or an explicit period in which old writers are no longer supported—and make both reads and reconciliation honor that state. Add a regression where the old writer reads alpha, the new writer deletes it, the old writer saves another key from its stale snapshot, and both Load(alpha) and a later new-version Save keep alpha absent.

  • [P1] Do not replace a valid oversized legacy blob with an arbitrary subset
    internal/oauth/store.go:917
    When the reconciled combined representation exceeds the new 3800-byte write budget, this loop keeps only map entries that fit in patchTokens and overwrites the legacy account with that subset at line 946. Map iteration makes the retained set nondeterministic. Pre-PR Linux keyring storage has no corresponding single-secret limit, so an existing valid legacy map with several provider logins can be truncated on the first new-version save; a concurrently running or downgraded old binary then silently loses whichever credentials were omitted. The final len(enc) guard cannot protect this case because each individual addition has already been capped at line 931.

    The root cause is using the new macOS-safe write bound as a lossy migration transform for data the old backend legitimately stored on other platforms. Never replace a complete legacy representation with a partial one. If a complete compatibility write cannot fit, preserve the existing legacy value unchanged and make the migration state explicitly represent that old readers can no longer receive new changes, or adopt a lossless versioned migration protocol. Add a Linux-compatible fixture whose legacy payload exceeds 3800 bytes, migrate it, and verify that an old-format reader never observes a subset.

  • [P1] Renew the index lease while waiting for the legacy lock
    internal/oauth/store.go:1215
    withLeasedLocks acquires the shared index lock first, then can block acquiring the config-root-specific legacy lock, but it only starts refreshing mtimes after all acquisitions succeed. If that second lock is stale or held for 30 seconds, another new process on a different config root can reclaim the unrefreshed shared index lock and enter its own keyring update. When the first waiter finally acquires its legacy lock, both processes execute the index read-modify-write concurrently, reintroducing the lost-update race that lockPath is meant to prevent.

    The root cause is separating lock acquisition from lease ownership: a lock is already logically owned while the function waits for the next one. Start the lease lifecycle immediately after each successful acquisition (and stop/release it on every error or panic), or change the lock ordering/protocol so the shared lock is never held while a second acquisition can block. Add a controlled test that holds or leaves stale the second lock beyond fileLockStaleAfter, verifies a competing root cannot reclaim the first lock, and then verifies the two writes remain serialized.

Stop dual-writing the shared legacy blob so uncoordinated old writers cannot be clobbered or truncated, record chunked tombstones for logout durability, and renew each multi-lock lease as soon as it is acquired. Add regressions for the remaining migration coexistence P1s.
@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed the latest review (legacy coexistence P1s).

Changes

  • Never dual-write or delete the shared legacy combined entry. Indexed per-key storage is the sole writable representation for new binaries; legacy is a frozen read-only discovery source.
  • Durable tombstones (oauth-tokens-tombstones, chunked like the index) so logout survives a stale old-writer rewrite of the legacy blob.
  • No partial legacy subset writes under the macOS size budget (oversized legacy left byte-identical).
  • Lease refresh starts on each lock acquisition, so waiting on the legacy lock cannot leave the shared index lock looking abandoned.

Test plan

  • go test ./internal/oauth/ -race -count=1
  • New regressions for unobserved legacy write, durable logout vs stale snapshot, oversized legacy freeze, and multi-lock lease refresh while waiting.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Keep tombstones authoritative across interrupted writes
    internal/oauth/store.go:627
    Consider a legacy-only installation containing alpha: Delete(alpha) first reads it through this branch, adds alpha to tombstones, and successfully writes that marker at line 881. If the first writeKeyIndex call at line 898 fails (or the process is interrupted), no live index exists. A later Load(alpha) returns the untouched legacy blob directly from this branch and never reads the already-durable tombstone, so the supposedly logged-out access/refresh token becomes usable again. This is not merely an error-return retry case: process interruption after the tombstone write produces persistent contradictory state.

    The inverse transition is also unsafe. After that logout, Save(alpha, replacement) removes the tombstone at lines 797-800 and persists its removal before either the replacement index or token entry is durable. If writeKeyIndex or kr.Set fails, the missing-entry fallback at lines 665-678 imports the previous legacy alpha; a failed re-login has silently restored a credential the user deliberately revoked.

    The root cause is treating a tombstone, index, and token entry as independently committed state despite readers interpreting them as one authorization decision. Make this a single recoverable transition: all legacy reads, including the no-index path, must honor tombstones; retain a tombstone until a replacement entry and its authoritative index have committed; and add failure-injection tests at every boundary of initial migration, delete, and re-login.

  • [P2] Do not make historical logouts a permanent 512-key quota
    internal/oauth/store.go:973
    The frozen legacy account means tombstones never naturally expire, yet writeTombstones rejects the 513th distinct key before write reaches either the live-entry delete or index shrink. A user who has logged out of 512 valid provider/MCP keys over time can still save a 513th credential, but Delete for it returns an error and leaves its live token in place. This is reachable through ordinary namespaced MCP keys, not malformed keyring data, and survives restart because the tombstone index itself is persistent.

    The root cause is reusing the live-index capacity limit for unbounded deletion history while retaining the legacy representation indefinitely. Either give deletion markers a representation and lifecycle that can outgrow live credentials (for example, a compact/generation-based migration protocol), or establish a bounded compatibility-retirement point after which tombstones can be safely collected. Please add a regression that creates more than maxKeyringIndexKeys distinct login/logout cycles and verifies every later logout still removes its credential.

  • [P2] Keep the keyring lock stable when user lookup is unavailable
    internal/oauth/store.go:293
    If user.Current() fails, this fallback uses os.UserHomeDir(), which on Unix can resolve the process's ambient HOME. Two same-user sandboxed or launcher processes with different HOME values can therefore compute separate paths, acquire both locks successfully, read the same fixed zero/oauth-tokens-index account, and each publish an index containing only its own update. The later writer then hides the other process's credential even though both operations reported success.

    The root cause is deriving the lock from process-local environment state after the code correctly identifies the keyring account as process-independent. Use a UID-stable fallback that does not consult HOME (the existing UID-scoped temporary naming is one viable building block), and exercise the user.Current failure path with different ambient home directories to prove concurrent stores choose one lock.

  • [P2] Do not time out behind a healthy large keyring operation
    internal/oauth/lock.go:119
    A live holder refreshes its lease, but contenders still hit the unconditional two-minute ceiling. The new format permits 512 indexed keys; read visits each entry, and a save can read the set before writing every desired entry. Each real keyring command is allowed ten seconds in internal/keyring/keyring.go, so even a small fraction of slow commands can make a healthy pass exceed two minutes. At that point every concurrent Load, Status, Save, or Delete returns oauth: timed out acquiring token lock despite observing a continuously refreshed lease.

    The root cause is having two incompatible definitions of a healthy holder: lease renewal tells the waiter the operation is making progress, while the fixed absolute deadline rejects that same operation without considering bounded work or caller cancellation. Make waiting context/cancellation-aware and continue behind a healthy lease, or derive a defensible bound from the maximum indexed work and per-command timeout. Add a controlled slow-keyring test that holds a lease beyond two minutes' scaled equivalent and verifies a concurrent operation waits or receives caller cancellation rather than a spurious store timeout.

Keep tombstones authoritative until replacement credentials commit, allow bounded deletion history beyond the live-token cap, stabilize fallback lock identity, and wait behind healthy renewed leases.

Refs Gitlawb#668
@euxaristia

Copy link
Copy Markdown
Contributor Author

Verified this against jatmn's last review — all 4 findings (tombstone authority across interrupted writes in both directions, the 512-key tombstone quota, ambient-HOME lock-identity divergence, and the hard 2-minute lock ceiling) are addressed in the latest pushed commit (ad2032f), each with a matching regression test using fault injection. Build, vet, a CRLF-safe git diff --check format check, and the full internal/oauth suite all pass. Ready for re-review.

jatmn
jatmn previously approved these changes Aug 6, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@euxaristia

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 28 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (8)
internal/oauth/store.go (6)

538-541: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fix the stale withLock doc on the interface.

The comment states the keyring backend has no cross-process exclusion and relies only on Store.mu. That is no longer true: keyringBlob.withLock (Line 1384) acquires lockPath and legacyLockPath through withLeasedLocks. The whole PR depends on that lock, so a contradicting contract comment is an invitation to remove it later.

📝 Suggested doc fix
-	// withLock runs fn under whatever cross-process exclusion the backend offers
-	// (a lock file for the file backend; none for the keyring, which is the
-	// authoritative store and is serialized within the process by Store.mu).
+	// withLock runs fn under whatever cross-process exclusion the backend offers:
+	// a lock file beside the store for the file backend, and the leased
+	// identity-scoped keyring locks (lockPath plus legacyLockPath) for the
+	// keyring backend. Store.mu covers the in-process case for both.
 	withLock(now func() time.Time, fn func() error) error
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 538 - 541, Update the interface comment
for withLock to accurately state that keyring operations acquire the lockPath
and legacyLockPath locks via withLeasedLocks, rather than claiming keyring has
no cross-process exclusion; retain the file-backend locking description.

1395-1395: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

location() now names the index account in user-facing errors.

Store.FilePath() returns this string, and readState interpolates it into oauth: invalid token store at %s and oauth: decrypt token store at %s. The value changed from keyring:zero/oauth-tokens to keyring:zero/oauth-tokens-index. Tokens do not live in the index account, so a user debugging a keychain problem is pointed at the wrong item.

Consider "keyring:" + b.service alone, which names the keychain service the user would actually search for.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` at line 1395, Update keyringBlob.location() to
return only the keyring service identifier, omitting b.indexAccount, so
Store.FilePath() and readState error messages point users to the actual keychain
service rather than the index account.

925-930: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Each Save rewrites every token entry, not just the mutated one.

Step 3 iterates the full desired key set and calls kr.Set for every key. A user with eight logged-in providers pays eight security add-generic-password invocations (or eight secret-tool store calls) on every single Save, including the automatic refresh that runs during normal command execution. Only one entry actually changed.

The information needed to avoid this is already present. state was produced by read() under the same held lock, so any key whose entry read() resolved from its own indexed entry is byte-identical to what step 3 would rewrite. Only these keys need a Set:

  • keys listed in mutations with deleted == false
  • keys merged in from legacyTokens at Line 856
  • prior index keys whose entry read() could not resolve (recovery of a torn write)

Track that third set while computing livePrior, then restrict the loop. This also shrinks the interruption surface the failAt tests walk, and it keeps the invariant intact because the union index still lists every key before any entry is touched.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 925 - 930, The Save flow should avoid
rewriting unchanged token entries. While computing livePrior, track prior index
keys whose read state could not resolve, then build the write set from
non-deleted mutations, keys merged from legacyTokens, and those unresolved prior
keys; update the Step 3 loop to call kr.Set only for that union while preserving
the full union index.

987-1026: 🩺 Stability & Availability | 🔵 Trivial

Tombstones grow monotonically with no reclamation path.

A tombstone is added on every Delete and removed only when the same key is saved again, or when the set drains to empty. Keys are user-controlled (ProviderKey, MCP keys), so a long-lived install that logs in and out of many distinct names accumulates entries permanently. At maxKeyringTombstoneKeys (16384) writeTombstones returns an error, and because it runs as step 1 of write, every subsequent Save and Delete fails, not just the one that crossed the line.

There is no safe cheap prune here: dropping a tombstone because the key is currently absent from the legacy blob would reintroduce the exact race TestStoreKeyringLogoutDurableAgainstOldWriterStaleSnapshot covers, since the stale old-writer snapshot arrives after the delete.

Two options worth considering before this ships:

  • Give the tombstone set an expiry. The legacy account is a compatibility artifact of one release window; a timestamp per tombstone lets the set drop entries once no pre-PR binary can plausibly still hold a stale snapshot.
  • Retire the whole tombstone mechanism together with legacy reads, on a defined release. Record that plan in the package comment so the cap is not load-bearing forever.

At minimum, degrade gracefully instead of hard-failing all writes at the cap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 987 - 1026, Update writeTombstones so
reaching maxKeyringTombstoneKeys does not make subsequent Save and Delete
operations fail; add a graceful bounded-cap behavior that preserves durable
tombstone protection while allowing writes to continue. Document the chosen
reclamation or retirement strategy in the package comment, and ensure
writeTombstones still surfaces genuine keyring read/write errors.

1078-1083: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drop the log.Printf and return the error only.

errKeyringIndexTooManyKeys both logs and returns the same message. Callers surface the returned error, so the user sees the condition twice, once as a timestamped log line on stderr and once as the command's error. Nothing else in this package logs from a helper. This also fires on every retry of a corrupt index, so it repeats.

♻️ Proposed fix
 func errKeyringIndexTooManyKeys(count, limit int) error {
-	log.Printf("warning: oauth: keyring token index lists %d keys, over the %d-key cap", count, limit)
 	return fmt.Errorf("oauth: keyring token index lists %d keys, over the %d-key cap", count, limit)
 }

Remove the now-unused log import at Line 8.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 1078 - 1083, Update
errKeyringIndexTooManyKeys to remove the duplicate log.Printf call and only
return the formatted error; then remove the now-unused log import from the
package.

632-643: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Every read now spawns three extra keyring lookups; consider caching within one read.

On macOS each KeyringClient.Get runs a security subprocess (see internal/keyring). A single Load of one provider now costs, at minimum:

  1. index header Get
  2. tombstone index Get (readTombstonesreadKeyIndex)
  3. the token entry Get
  4. the unconditional legacy Get at Line 720

That is four process spawns where the previous design used one, and steps 2 and 4 run on every read forever, not just during the mixed-version window. Every CLI command that touches a token pays it.

Two cheap reductions, neither of which changes the contract:

  • Skip the legacy Get when the tombstone set and index already resolved every requested key and the legacy account was already observed absent in this process. A per-keyringBlob "legacy absent" memo would remove step 4 for the overwhelmingly common post-migration install.
  • Fold the tombstone read into the same pass so read() performs one readKeyIndex for tombstones instead of repeating it in both read() and write().

Not a correctness defect. Flagging it because this is the interactive path for every OAuth-backed command.

Also applies to: 716-731

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 632 - 643, Reduce repeated keyring
subprocesses in keyringBlob.read by reusing the index data when resolving
tombstones instead of invoking readKeyIndex again, and add a per-keyringBlob
memo for an observed-absent legacy account. Skip the legacy Get in the read
fallback when the index and tombstones cover all requested keys and that absence
memo is set; update the memo from legacy lookup results while preserving
existing resolution behavior.
internal/oauth/store_keyring_test.go (2)

375-377: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale comment: nothing is dual-written to legacy any more.

The comment lists "dual-written legacy" among the values under test. New code never writes keyringLegacyAccount, as TestStoreKeyringBackendRoundTrip at Lines 70-74 asserts. The same wording appears in the failure message at Line 380 ("index/legacy cap regression").

📝 Suggested fix
-	// Every keyring value (index chunks, per-key tokens, dual-written legacy)
-	// must stay under the single-entry cap with generous framing margin.
+	// Every keyring value (index chunks and per-key token entries) must stay
+	// under the single-entry cap with generous framing margin.
 	const entryCeiling = 3800
 	for k, v := range kr.data {
 		if len(v) > entryCeiling {
-			t.Fatalf("keyring entry %q is %d bytes, want <= %d (index/legacy cap regression)", k, len(v), entryCeiling)
+			t.Fatalf("keyring entry %q is %d bytes, want <= %d (index cap regression)", k, len(v), entryCeiling)
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store_keyring_test.go` around lines 375 - 377, Update the
comment above entryCeiling and the related failure message in
TestStoreKeyringBackendRoundTrip to remove references to dual-written legacy
values and describe only the current index chunks and per-key tokens being
capped.

411-411: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Bound the fault-injection loop so a regression fails instead of hanging.

for failAt := 1; ; failAt++ terminates only when opsUsed < failAt. If a future change makes the mutating-op count grow with failAt (for example, a retry added to the write path), this loop never exits and the package hangs until the Go test timeout. The same pattern is in TestStoreKeyringDeleteInterruptionsLeaveNoInvisibleTokens (Line 482) and TestStoreKeyringMigrationInterruptionsPreserveLegacyTokens (Line 718).

Add a hard ceiling with a clear failure:

const maxBoundaries = 200
for failAt := 1; failAt <= maxBoundaries; failAt++ {
    ...
    if opsUsed < failAt {
        return
    }
}
t.Fatalf("write path used more than %d mutating operations; loop did not converge", maxBoundaries)

Also applies to: 468-473

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store_keyring_test.go` at line 411, Bound the fault-injection
loops in TestStoreKeyringInterruptions,
TestStoreKeyringDeleteInterruptionsLeaveNoInvisibleTokens, and
TestStoreKeyringMigrationInterruptionsPreserveLegacyTokens with a shared maximum
such as maxBoundaries = 200. Replace each unbounded loop with a bounded failAt
loop, retain the existing successful-convergence return, and add a clear
t.Fatalf after the loop when the mutating operation count never falls below the
limit.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/oauth/flow_test.go`:
- Around line 302-316: Expand TestRefreshPreservesScopesWhenOmitted to assert
the outbound scope form value is "custom-scope" when current token scopes are
present. Add a second case with empty current.Scopes, verifying both the request
scope and returned token use cfg.Scopes ("fallback-scope"), while preserving the
existing response and error assertions.

In `@internal/oauth/flow.go`:
- Around line 180-184: Update the refresh flow to select the effective scopes
from current.Scopes, falling back to cfg.Scopes when empty, before constructing
the token request form. Use this scopes value in form.Set("scope", ...) and
retain it for the base Token so the request and returned token share the same
scope set.

In `@internal/oauth/lock.go`:
- Around line 103-114: Update the lock-wait loop around ReclaimStaleLock so that
when its callback returns true and the function returns false, nil, it preserves
that result and refreshes idleDeadline before the timeout check. Add a
regression test covering reclaim work that runs past the prior deadline and
verifies the lock wait does not time out.
- Around line 90-95: The lock handling around ReclaimStaleLock must reject
future modification times as healthy leases. Compute the lock age once, extend
idleDeadline only when age is non-negative and within fileLockStaleAfter, apply
the same condition in the ReclaimStaleLock callback, and add a regression test
covering a future mtime.
- Around line 90-95: Harden stale-lock handling around lockPath and
ReclaimStaleLock by validating ownership and every traversed lock-root
component, or by using rooted no-follow operations for inspection, reclaim,
refresh, and release so symlinks/reparse points cannot redirect operations.
Apply the protection consistently across the lock lifecycle, including os.Stat,
stale-lock reclamation, refresh, and release, while preserving keyring mutual
exclusion. Add substitution-attack tests covering Linux, macOS, and Windows.

In `@internal/oauth/store_keyring_test.go`:
- Around line 107-114: The synthetic JWT-shaped token fixtures in Token,
including the corresponding fixtures near lines 1796 and 2047, trigger
Betterleaks false positives. Add the scanner’s inline allow annotation to all
three fixture lines, or configure an exclusion for internal/oauth/*_test.go,
while preserving the scanner’s normal leak detection elsewhere.
- Around line 1018-1041: Redirect keyring test lock paths into per-test
temporary homes by adding and using a useTempLockHome helper before every test
that constructs a keyring Store. Preserve TestKeyringLockPathIsPerUser and the
fallback test as the only tests observing real identity. Update path assertions
to canonicalize paths rather than comparing raw temporary-directory spellings,
maintaining cross-platform behavior.
- Around line 616-644: Update TestStoreKeyringWithLockRefreshesLease so it does
not require strictly increasing lock mtime values on coarse-granularity
filesystems. Prefer asserting !second.Before(first) and that the refreshed
timestamp remains within fileLockStaleAfter of the current time, or extend the
sleep beyond the worst-case filesystem timestamp granularity while preserving
the lease-freshness check.
- Around line 2096-2139: Run the race-enabled OAuth test suite with go test
-race ./internal/oauth/... before merging, and record the command and its result
in the pull request description. No code changes are required.

In `@internal/oauth/store.go`:
- Around line 1174-1181: Update readKeyIndex to report when a referenced chunk
is missing instead of silently continuing, and propagate that condition through
write. When this condition is present, skip the final index-shrink step while
preserving the union index; retain current reconciliation and stale-chunk
cleanup behavior for intact reads.
- Around line 1349-1374: Ensure withLeasedLocks always invokes releaseAll
exactly once by deferring cleanup immediately after defining the lease
collection, then remove the explicit normal-return cleanup while preserving
acquisition-error cleanup semantics without double release. Add a regression
test that panics inside fn, recovers, verifies the lock file is removed, and
confirms a second withLeasedLocks call acquires promptly.
- Around line 1309-1342: Update startLease and leasedPath.release to verify the
lock still belongs to the current holder before refreshing, detect replacement
or refresh errors, and propagate lease loss so the owning operation stops
instead of continuing. Ensure release handles the failed-lease state safely, and
add a regression test that replaces the lock during a simulated suspension and
verifies the original holder exits without refreshing or entering the critical
section.
- Around line 301-306: Update keyringFallbackLockDir to use a UID-scoped
fallback directory rather than the shared /tmp path, creating it with mode 0700
when absent. Validate the directory’s owner and permissions before returning it,
and fail closed if validation fails; preserve os.TempDir for Windows.

---

Nitpick comments:
In `@internal/oauth/store_keyring_test.go`:
- Around line 375-377: Update the comment above entryCeiling and the related
failure message in TestStoreKeyringBackendRoundTrip to remove references to
dual-written legacy values and describe only the current index chunks and
per-key tokens being capped.
- Line 411: Bound the fault-injection loops in TestStoreKeyringInterruptions,
TestStoreKeyringDeleteInterruptionsLeaveNoInvisibleTokens, and
TestStoreKeyringMigrationInterruptionsPreserveLegacyTokens with a shared maximum
such as maxBoundaries = 200. Replace each unbounded loop with a bounded failAt
loop, retain the existing successful-convergence return, and add a clear
t.Fatalf after the loop when the mutating operation count never falls below the
limit.

In `@internal/oauth/store.go`:
- Around line 538-541: Update the interface comment for withLock to accurately
state that keyring operations acquire the lockPath and legacyLockPath locks via
withLeasedLocks, rather than claiming keyring has no cross-process exclusion;
retain the file-backend locking description.
- Line 1395: Update keyringBlob.location() to return only the keyring service
identifier, omitting b.indexAccount, so Store.FilePath() and readState error
messages point users to the actual keychain service rather than the index
account.
- Around line 925-930: The Save flow should avoid rewriting unchanged token
entries. While computing livePrior, track prior index keys whose read state
could not resolve, then build the write set from non-deleted mutations, keys
merged from legacyTokens, and those unresolved prior keys; update the Step 3
loop to call kr.Set only for that union while preserving the full union index.
- Around line 987-1026: Update writeTombstones so reaching
maxKeyringTombstoneKeys does not make subsequent Save and Delete operations
fail; add a graceful bounded-cap behavior that preserves durable tombstone
protection while allowing writes to continue. Document the chosen reclamation or
retirement strategy in the package comment, and ensure writeTombstones still
surfaces genuine keyring read/write errors.
- Around line 1078-1083: Update errKeyringIndexTooManyKeys to remove the
duplicate log.Printf call and only return the formatted error; then remove the
now-unused log import from the package.
- Around line 632-643: Reduce repeated keyring subprocesses in keyringBlob.read
by reusing the index data when resolving tombstones instead of invoking
readKeyIndex again, and add a per-keyringBlob memo for an observed-absent legacy
account. Skip the legacy Get in the read fallback when the index and tombstones
cover all requested keys and that absence memo is set; update the memo from
legacy lookup results while preserving existing resolution behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fd435ae6-713a-4b4e-8385-2c9ea9bea08b

📥 Commits

Reviewing files that changed from the base of the PR and between 097c265 and ad2032f.

📒 Files selected for processing (5)
  • internal/oauth/flow.go
  • internal/oauth/flow_test.go
  • internal/oauth/lock.go
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go

Comment thread internal/oauth/flow_test.go
Comment thread internal/oauth/flow.go Outdated
Comment thread internal/oauth/lock.go
Comment thread internal/oauth/lock.go
Comment thread internal/oauth/store_keyring_test.go
Comment thread internal/oauth/store_keyring_test.go
Comment thread internal/oauth/store.go Outdated
Comment thread internal/oauth/store.go
Comment thread internal/oauth/store.go Outdated
Comment thread internal/oauth/store.go
Defer lock release so a recovered panic cannot leave a forever-refreshed
lease that wedges every waiter. Make lease Chtimes ownership-aware and
fail closed when the lock is replaced mid-critical-section. Prefer the
token's scopes on refresh, reject future mtimes as healthy leases, use a
private validated fallback lock directory, skip index shrink when a
referenced chunk is missing, and isolate keyring tests from the real home.

Refs Gitlawb#668
@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed open CodeRabbit findings on 797c6ff7 (post-ad2032fb).

Critical

  • Panic leaks lease (withLeasedLocks): defer releaseAll() (idempotent) so a recovered panic always stops the lease goroutine and removes our lock. Regression: TestWithLeasedLocksReleasesOnPanic.
  • Lease refresh ownership: startLease re-checks the lock token before Chtimes, sets lost, and withLeasedLocks fails closed after fn. Regression: TestLeaseRefreshStopsWhenLockReplaced.

Major

  • Refresh scopes: select current.Scopes (fallback cfg.Scopes) before building the form; send that set on the wire. Tests assert outbound scope form values for both branches.
  • Future mtimes: compute age once; extend idle deadline only when age >= 0 && age <= fileLockStaleAfter; same non-negative check in the reclaim callback. Regression: TestAcquireFileLockTimesOutOnFutureMtime. Also refresh idle deadline when reclaim finds a live lock.
  • Fallback lock dir: UID-scoped private 0700 dir under the process temp root (validated owner/perm); Windows keeps os.TempDir().
  • Test home isolation: TestMain stubs currentOSUser to a process temp home so the suite does not write locks into the real user home; TestKeyringLockPathIsPerUser restores real identity.

Minor / related

  • Missing index chunk: readKeyIndex reports incomplete; write skips the final shrink so damaged chunks cannot orphan keychain entries. Regression: TestWriteSkipsIndexShrinkWhenChunkMissing.
  • Coarse-FS mtime flake: assert non-stale rather than strictly-newer.
  • Race suite: go test -race ./internal/oauth/ -count=1 passed.

Intentionally not expanded

  • Full symlink/reparse-point binding of every lock root component (heavy, platform-specific no-follow work beyond this pass).
  • betterleaks fixture annotations (no scanner config in-repo; fixtures are synthetic).

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Rate Limit Exceeded

@euxaristia have exceeded the limit for the number of chat messages per hour. Please wait 17 minutes and 25 seconds before sending another message.

…hip.

Skip shrinking and keep prior chunk advertisements when a keyring index
continuation is missing so a restored chunk can still reconcile keys.
Always re-check lock ownership after Chtimes, and replace JWT-shaped
test fixtures with opaque bulk strings for secret scanners.

Refs Gitlawb#668
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants