Summary
Running multiple opencode processes concurrently (common when you keep several terminal sessions open) against the same store causes an infinite loop of: re-auth succeeds → minutes later the account is marked authInvalid with a 401 token_expired from the Usage API → re-auth again → repeat. Re-authenticating never actually fixes it for more than a few minutes.
Reproduced on v1.4.3 (npm) with 4 concurrent opencode processes + the web dashboard, all pointed at the same ~/.config/opencode-multi-auth/accounts.json.
Root causes found (2 distinct bugs)
1. rotation.js — getNextAccount() saves a stale in-memory snapshot after an await
let store = loadStore(); // snapshot taken here
...
const token = await ensureValidToken(candidate); // network call, can take seconds
...
store = updateAccount(candidate, { usageCount: ..., lastUsed: now, ... }); // this part is safe, reloads internally
store.activeAlias = candidate;
store.rotationIndex = nextIndex(candidate);
saveStore(store); // <-- writes the ENTIRE store using the snapshot from before the await
If another process refreshes/re-authenticates the same account while this await is in flight, saveStore(store) overwrites the whole file with the pre-await copy, clobbering the fresh tokens the other process just wrote. Same pattern exists in the force-mode branch a few lines above.
Confirmed with a minimal repro (two async flows: one does load → 300ms delay (simulating the network await) → save; the other does a concurrent "re-auth" at the 100ms mark). Without a fix, the final accessToken on disk can be the stale one depending on timing.
2. limits-refresh.js / usage-limits.js — rate-limit refresh queue uses a stale accessToken snapshot
refresh-queue.js builds a queue from an accounts array captured once at the start of a refresh cycle (startRefreshQueue(accounts, alias)), then calls refreshRateLimitsForAccount(account) for each one. With concurrency limits, an account near the end of the queue can be processed minutes after the snapshot was taken. usage-limits.js:95 then does:
const token = account.accessToken?.trim();
using that stale token to call the real OpenAI Usage API. If the token was rotated via re-auth in the meantime, this triggers a genuine 401 token_expired from OpenAI's servers — even though the token stored on disk is valid — and the account then gets markAuthInvalid()'d.
This matches the exact pattern observed in logs/codex-soft.log:
11:13:34 Re-auth completed for personal by dashboard
11:32:33 Skipping limits probe for personal: Usage API returned 401 token_expired <- 19 min later, stale snapshot
11:35:33 Skipping limits probe for personal: Usage API returned 401 token_expired
Contributing factor
store.js's saveStore() does a full read-modify-write of accounts.json with only an in-memory writeLock/writeLockQueue (scoped to a single process). There's no cross-process locking (no flock, no lockfile), so any two processes doing load→mutate→save around the same time can race, independent of bugs 1 and 2 above.
Suggested fixes (I've applied and tested these locally as a patch)
- In
getNextAccount(), reload the store fresh from disk (loadStore()) right before the final saveStore(store) call, instead of reusing the pre-await snapshot. Applied to both the force-mode branch and the normal candidate-selection branch.
- In
refreshRateLimitsForAccount(account), reload the account fresh from the store (loadStore().accounts[account.alias]) before using account.accessToken, instead of trusting the snapshot passed in from the queue.
- (defense in depth) Add a real cross-process lock (e.g. a lockfile with
mkdirSync/stale-lock detection, or proper-lockfile) around saveStore/the load-mutate-save cycle in addAccount/removeAccount/updateAccount/setActiveAlias, since the current writeLock only protects against races within a single process.
Repro / environment
- Package:
@guard22/opencode-multi-auth-codex@1.4.3 (npm, matches latest published)
- 4 concurrent
opencode CLI processes + 1 opencode-multi-auth web dashboard, all sharing one OpenAI "personal" account
- Node v22.23.2, Linux
Happy to open a PR with the patch (points 1 and 2 fully tested; point 3 is a simple lockfile-based mutex, also tested) if that's useful — let me know your preference on locking implementation (custom mkdirSync-based vs. a dependency like proper-lockfile).
Summary
Running multiple
opencodeprocesses concurrently (common when you keep several terminal sessions open) against the same store causes an infinite loop of: re-auth succeeds → minutes later the account is markedauthInvalidwith a401 token_expiredfrom the Usage API → re-auth again → repeat. Re-authenticating never actually fixes it for more than a few minutes.Reproduced on
v1.4.3(npm) with 4 concurrentopencodeprocesses + the web dashboard, all pointed at the same~/.config/opencode-multi-auth/accounts.json.Root causes found (2 distinct bugs)
1.
rotation.js—getNextAccount()saves a stale in-memory snapshot after anawaitIf another process refreshes/re-authenticates the same account while this
awaitis in flight,saveStore(store)overwrites the whole file with the pre-await copy, clobbering the fresh tokens the other process just wrote. Same pattern exists in the force-mode branch a few lines above.Confirmed with a minimal repro (two async flows: one does load → 300ms delay (simulating the network await) → save; the other does a concurrent "re-auth" at the 100ms mark). Without a fix, the final
accessTokenon disk can be the stale one depending on timing.2.
limits-refresh.js/usage-limits.js— rate-limit refresh queue uses a staleaccessTokensnapshotrefresh-queue.jsbuilds a queue from anaccountsarray captured once at the start of a refresh cycle (startRefreshQueue(accounts, alias)), then callsrefreshRateLimitsForAccount(account)for each one. With concurrency limits, an account near the end of the queue can be processed minutes after the snapshot was taken.usage-limits.js:95then does:using that stale token to call the real OpenAI Usage API. If the token was rotated via re-auth in the meantime, this triggers a genuine
401 token_expiredfrom OpenAI's servers — even though the token stored on disk is valid — and the account then getsmarkAuthInvalid()'d.This matches the exact pattern observed in
logs/codex-soft.log:Contributing factor
store.js'ssaveStore()does a full read-modify-write ofaccounts.jsonwith only an in-memorywriteLock/writeLockQueue(scoped to a single process). There's no cross-process locking (no flock, no lockfile), so any two processes doing load→mutate→save around the same time can race, independent of bugs 1 and 2 above.Suggested fixes (I've applied and tested these locally as a patch)
getNextAccount(), reload the store fresh from disk (loadStore()) right before the finalsaveStore(store)call, instead of reusing the pre-awaitsnapshot. Applied to both the force-mode branch and the normal candidate-selection branch.refreshRateLimitsForAccount(account), reload the account fresh from the store (loadStore().accounts[account.alias]) before usingaccount.accessToken, instead of trusting the snapshot passed in from the queue.mkdirSync/stale-lock detection, orproper-lockfile) aroundsaveStore/the load-mutate-save cycle inaddAccount/removeAccount/updateAccount/setActiveAlias, since the currentwriteLockonly protects against races within a single process.Repro / environment
@guard22/opencode-multi-auth-codex@1.4.3(npm, matches latest published)opencodeCLI processes + 1opencode-multi-auth webdashboard, all sharing one OpenAI "personal" accountHappy to open a PR with the patch (points 1 and 2 fully tested; point 3 is a simple lockfile-based mutex, also tested) if that's useful — let me know your preference on locking implementation (custom mkdirSync-based vs. a dependency like
proper-lockfile).