Repository navigation
Description
Description
refreshIfNeeded() returns null for two conditions that are deferrals, not credential failures:
src/credentials.ts:513-524— an armed rate-limit cooldownsrc/credentials.ts:539-549— a sibling process holds the cross-process refresh lock and publishes nothing inside the 5swaitForAdoptbudget
null is this module's signal for "no usable credentials exist". src/index.ts:236-255 reads it that way and shows the user:
opencode-claude-auth: Proactive token refresh failed. Run `claude` to re-authenticate.
The proactive sync timer asks an hour ahead of expiry (PROACTIVE_REFRESH_THRESHOLD_MS), so the token those branches are asked about is normally still perfectly healthy. The result is a "re-authenticate" prompt on a credential with hours of life left, and running claude does nothing because nothing is wrong.
performRefresh already has the correct guard for exactly this situation, twice:
src/credentials.ts:692—if (creds.expiresAt > Date.now() + CLI_FALLBACK_THRESHOLD_MS) return credson the transient pathsrc/credentials.ts:722— the same test before the CLI fallback
Only the two deferral branches are missing it.
Why it shows up so often. Every OpenCode process runs its own sync timer against one shared Claude OAuth credential and one advisory lock file. Each token rotation is therefore a race that exactly one process wins; every loser takes the lock-busy branch. On this machine there are 10 concurrent OpenCode processes, and the keychain entry rotated at 10:22:45 local — the same minute the warning appeared. Token TTL is 8h and refresh starts 1h out, so the burst recurs roughly every 7 hours.
proactiveRefreshWarned in src/index.ts:226 does latch the warning to once per outage per process, so this is not a 5-minute repeat within one process — it is one warning per losing process per rotation.
Steps to reproduce
Reproduced end-to-end against the published 2.1.6 dist with real (healthy) keychain credentials. No OAuth request, no keychain write, no claude spawn — the lock is taken before anything else runs, and the lock directory is redirected so live OpenCode instances are unaffected.
// repro.mjs
import { mkdtempSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
process.env.OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR = mkdtempSync(
join(tmpdir(), "claude-auth-repro-lock-"),
)
process.env.CLAUDE_AUTH_DEBUG = process.argv[3]
const dist = process.argv[2]
const { initLogger } = await import(`${dist}/logger.js`)
const { acquireRefreshLock } = await import(`${dist}/refresh-lock.js`)
const { initAccounts, refreshIfNeeded } = await import(`${dist}/credentials.js`)
const { readAllClaudeAccounts } = await import(`${dist}/keychain.js`)
initLogger()
const accounts = readAllClaudeAccounts()
initAccounts(accounts)
const target = accounts[0]
const ttlMin = Math.round((target.credentials.expiresAt - Date.now()) / 60_000)
console.log(`token life left: ${ttlMin} min`)
// Simulate a sibling OpenCode process that won the refresh race.
const held = acquireRefreshLock(target.source)
// A threshold above the remaining life forces the "needs refresh" path,
// exactly as the 1h proactive timer does once the token is inside its window.
const result = await refreshIfNeeded(undefined, (ttlMin + 60) * 60_000)
console.log(`refreshIfNeeded returned: ${result ? "credentials" : "null"}`)
held?.release()node repro.mjs ~/.cache/opencode/packages/[email protected]/node_modules/opencode-claude-auth/dist /tmp/debug.log
Output:
token life left: 470 min
refreshIfNeeded returned: null
470 minutes of remaining life, and index.ts would tell the user to re-authenticate.
Expected: the still-usable credentials are returned and the next tick (5 min later) retries the refresh.
Operating system
macOS
Plugin version
2.1.6
OpenCode version
1.18.18
Debug logs
CLAUDE_AUTH_DEBUG output from the run above, with the repeated keychain_read / credentials_parsed poll lines trimmed:
{"ts":"2026-08-15T14:32:52.826Z","event":"keychain_list","servicesFound":["Claude Code-credentials"]}
{"ts":"2026-08-15T14:32:52.843Z","event":"account_config_dir","source":"Claude Code-credentials","configDir":"/Users/…/.claude"}
{"ts":"2026-08-15T14:32:52.844Z","event":"refresh_lock_acquired","source":"Claude Code-credentials"}
{"ts":"2026-08-15T14:32:52.859Z","event":"refresh_lock_busy","source":"Claude Code-credentials"}
{"ts":"2026-08-15T14:32:58.044Z","event":"keychain_read","service":"Claude Code-credentials","success":true}The trace ends at refresh_lock_busy plus 5s of adopt polling. There is no refresh_failed, no refresh_terminal, no refresh_exhausted — nothing actually failed. proactive_refresh_failed and the user-facing warning follow purely from the null.
Suggested fix
Apply the same guard the transient and CLI-fallback paths already use to both deferral branches: if the credentials still have more than CLI_FALLBACK_THRESHOLD_MS (60s) of life, return them instead of null.
That keeps the reactive path (getCachedCredentials, 60s threshold) byte-for-byte unchanged — it only reaches these branches when the token has under 60s left, where the guard is false and null still stands, so getCredentialsWithBackoff continues to wait the cooldown out. It only changes the proactive path, which is the one producing the false alarm. It also makes a null from the proactive timer mean something real again: under 60s of life and unable to refresh, which genuinely warrants "run claude".
Aside, not part of this report
An earlier analysis of this also suggested the plugin leaks its setInterval across instance disposal because it never returns a dispose hook. I could not confirm that: Hooks in @opencode-ai/plugin (checked 1.2.25 and 1.2.27) has no dispose member, so a plugin cannot currently return one. The timer is unref()'d at src/index.ts:261. Mentioning it only so it is not lost — it is not what this issue is about, and the fix above stands on its own.
Activity
fix: never surface a deferred refresh as a re-authenticate prompt
Toady00 commentedon Aug 16, 2026
Follow-up: the original report covered the case where the token is still healthy, but field testing turned up a second, more common trigger that the first fix does not cover. PR #273 has been extended.
The trigger
The warning correlates with the machine being locked with the screensaver running — not sleeping.
macOS applies timer coalescing / App Nap to background processes in that state, so the 5-minute
setIntervalinsrc/index.tsdoes not fire on schedule. The proactive window can pass entirely without a single tick. Every instance then converges at once when the machine becomes active again, by which point the token is at or past expiry.Measured on a machine with 3 concurrent OpenCode instances:
16:00:21, so the proactive window opened at15:00:21mdatshows the credential was rotated at20260816200021Z=16:00:21local — exactly at expiry, not during the hour-long windowIt is not the OAuth path
Ruled out by running a real refresh against the live endpoint with the same credentials:
{"event":"refresh_lock_acquired","source":"Claude Code-credentials"} {"event":"refresh_needed","expiresIn":22696498} {"event":"refresh_started","source":"oauth"} {"event":"refresh_success","source":"oauth"} {"event":"writeback_success","source":"Claude Code-credentials"}370ms end to end, clean rotation and writeback. The refresh path is healthy; it simply never gets called while the machine is locked.
Why the first fix was not enough
The herd converging at expiry is the worst case for the lock:
DEFAULT_LOCK_TTL_MSis 20swaitForAdoptgives up afterLOCK_ADOPT_WAIT_MS= 5srefreshViaClirunsclaudewith a 60s timeout and 2 attempts — up to 120sSo the winner can hold the lock for two minutes while every loser times out after five seconds. Because the token is already past
CLI_FALLBACK_THRESHOLD_MS, theexpiresIn > 60sguard added in the first commit evaluates false, and the loser still falls through to a barenull— producing "Runclaudeto re-authenticate" while a sibling process is actively completing the refresh that resolves it seconds later.Fix
Remaining lifetime was the wrong question. A deferral means another refresher owns the work right now; nothing is known to be wrong with the credentials regardless of how much life is left.
The deferral is now recorded and exposed as
wasRefreshDeferred(source), so the sync timer can distinguish "a sibling is refreshing" from "this token is dead" instead of inferring it fromnull. The marker resets at the top of everyrefreshIfNeededcall, so it only ever describes the call that just ran and cannot suppress a later genuine failure. Still-usable credentials are returned exactly as before — this only changes what the caller is told when there is nothing left to return, and the warn-once latch is left untouched on a deferral so a real outage still reports.Three tests added, each verified to fail against the previous commit:
marks a busy-lock deferral as deferred even when it must return nullclears the deferral marker once a refresh actually runs and failsproactive refresh timer stays quiet on a deferral even when credentials are expiredNote on the underlying cause
This makes the symptom correct — the plugin no longer reports a false credential failure. It does not make the proactive refresh fire on a locked machine; that is OS timer throttling and would need a different mechanism (for example, comparing wall-clock elapsed time against the last successful check rather than relying on interval fidelity). Worth tracking separately if the maintainer wants the proactive refresh to actually hold its schedule while the machine is idle.