Skip to content

feat: pasted setup-token accounts with automatic rotation on rate limits - #269

Open
psalkowski wants to merge 2 commits into
griffinmartin:mainfrom
psalkowski:main
Open

feat: pasted setup-token accounts with automatic rotation on rate limits#269
psalkowski wants to merge 2 commits into
griffinmartin:mainfrom
psalkowski:main

Conversation

@psalkowski

Copy link
Copy Markdown

Summary

Two related additions for people with more than one Claude subscription, plus one bug fix found along the way.

1. Pasted claude setup-token values as a credential source. Today multiple accounts means multiple Claude Code logins in the Keychain. claude setup-token already mints exactly what's needed — a long-lived, inference-only OAuth token — so this accepts them directly via opencode auth login ("Add Claude token"), several at once, plus OPENCODE_CLAUDE_AUTH_TOKENS / CLAUDE_CODE_OAUTH_TOKEN for headless use. Works with no Keychain and no Claude Code login on the machine.

These tokens carry no refresh token, so they're modelled as kind: "static" rather than as OAuth credentials with an empty refresh token. That distinction is load-bearing: every refresh site decides what to do from expiry plus refresh-token presence, so an empty refresh token is indistinguishable from a credential that failed to parse — which routes into claude CLI spawns and cross-account borrowing on every cache miss. isCredentialUsable() treats static credentials as always usable and refreshIfNeeded() short-circuits them before any refresh machinery runs.

2. Automatic rotation on rate limits. fetchWithRetry handles transient 429s and src/index.ts already re-reads the source in case an external tool rotated the credential — but nothing switches accounts on its own, so a session that exhausts its account stays exhausted even with healthy accounts available. This benches the limited account and retries on the next healthy one in priority order, within the same request.

Cooldown length comes from retry-after, then anthropic-ratelimit-unified-*-reset, then a short default. Two bounds are deliberate: an unexplained 429 gets only 60s (it's more likely a burst limit than an exhausted subscription, and writing an account off for hours on that evidence would be wrong), and every bench is capped at 6h (a bench is only "when to reconsider", so capping costs at most one extra 429 whereas an uncapped weekly reset would park an account for days). Benches persist across restarts and clear as soon as an account serves a request again.

Rotation policy lives in src/rotation.ts as pure bookkeeping over source strings, and deliberately does not decide when to rotate. fetchWithRetry is shared with the OAuth token endpoint, and a 429 from that must never bench a subscription — the same reasoning that kept specs/2026-07-29-external-credential-rotation-design.md out of http.ts. The trigger stays at the one call site in src/index.ts that can see an API response.

Ordering there is load-bearing: after the existing external-switch check (if another process already moved us to a healthy account, that costs no cooldown), and before the long-context beta loop but with long-context 429s excluded explicitly rather than by ordering — that error is a header problem every account shares, so rotating around it would bench the whole pool for a fault no account can avoid, and reaching the beta loop first would mean the account was already benched by then.

Notification goes through client.tui.showToast, not console.warn — the latter draws over the TUI, which is why API errors were moved off it in 2.0.1 and why a test asserts a quota 429 prints nothing.

3. Bug fix: hint: undefined in the account picker returned HTTP 500 from GET /provider/auth.

SchemaError: Expected string, got undefined
  at ["anthropic"][0]["prompts"][0]["options"][0]["hint"]

The schema requires the key to be absent, not undefined. This is latent on main today — it only triggers when the first listed account isn't the active one, which is uncommon there but routine once rotation exists or pasted tokens (sorted last) are active. Since that endpoint backs the TUI's /connect, the symptom is that switching accounts inside OpenCode fails with an opaque 500.

Worth noting for the existing test suite: buildSelectOptions in src/index.test.ts reimplemented the option builder and asserted the broken shape, so it couldn't catch this. Replaced with tests that read the real plugin's auth.methods prompts.

Also redacts sk-ant-* values in the debug log. Redaction covered JWT-shaped values and three key names, but sk-ant-oat… isn't JWT-shaped, so a token logged under any other key could reach a file the README describes as safe to attach to an issue.

Everything is additive — pasted tokens come after discovered accounts, and rotation is disableable with OPENCODE_CLAUDE_AUTH_ROTATE=0. No behaviour changes if you never paste a token and never hit a rate limit. Design notes in specs/2026-08-06-multi-token-rotation-design.md.

Related issue

None — happy to open one first if you'd prefer to discuss the approach before reviewing the diff.

Testing

make all green: lint 0 errors (4 warnings, all pre-existing on main), build clean, 419/419 tests pass.

One environment caveat worth flagging since it may bite others: ANTHROPIC_CLI_VERSION overrides version in billing header fails when CLAUDE_CODE_ENTRYPOINT is set in the shell — transforms.ts:358 reads it and defaults to sdk-cli, so running the suite from inside Claude Code (which sets it to cli) fails that assertion. It passes with the variable unset, and I confirmed the same failure on unmodified main at the same base commit, so it's unrelated to this PR. Happy to add an env guard to that test in a separate PR if useful.

New tests:

  • src/token-store.test.ts (39) — multi-token paste parsing, dedupe, partial-invalid input, future oat versions, validation messages that never echo the secret, content-derived ids, store CRUD, 0600 permissions, env-token precedence, and resilience to malformed or partially broken files.
  • src/rotation.test.ts (40) — cooldown derivation from each signal and the cap, bench persistence and the never-shorten rule, ordering and candidate selection, initial-account choice stepping over a benched selection, disabled-rotation behaviour.
  • src/index.test.ts (+8) — integration: a 429 rotating onto the next account with the retry carrying that account's token; rotating onto a pasted token; the limit surfacing once every account is exhausted (bounded, no loop); no rotation when disabled; no rotation on a long-context 429; configured order deciding both start and target; plus two schema-compatibility tests for the hint fix, which I verified fail without it.

Existing harnesses in credentials.test.ts / index.test.ts / keychain.test.ts needed the two new modules added to their temp-dir copies, and I pointed the new state files at temp paths so no test can touch real user state.

Verified end to end against a running server (opencode serve + GET /provider/auth → 200, all three auth methods serialising correctly) and in a live OpenCode session on macOS with two Keychain accounts and two pasted tokens.

Checklist

  • PR title follows Conventional Commits (feat:, fix:, docs:, chore:, etc.)
  • make all passes locally (runs lint, build, and test)
  • Tests added or updated where applicable
  • README or docs updated where applicable

Happy to split this into separate PRs — the hint fix stands alone and is worth landing regardless of the rest, and the token source and rotation are independent of each other. Say the word and I'll break it up.

Adds long-lived `claude setup-token` values as a credential source and
rotates off a rate-limited account onto the next healthy one.

Also fixes an undefined `hint` in the account picker that made
GET /provider/auth return 500, taking the TUI's /connect offline.
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds static setup-token accounts and automatic account rotation after rate limits, along with credential-store and logging hardening.

  • Adds persistent, environment-provided, and session-only static token sources.
  • Introduces persisted account cooldowns and bounded same-request rotation.
  • Fixes account-picker schema output and redacts Anthropic token-shaped values.
  • The changes at current HEAD address all three previously reported issues.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; current HEAD addresses the previously reported session-token persistence, unusable rotation-target, and temporary-file symlink issues.

Important Files Changed

Filename Overview
src/token-store.ts Adds validation, environment and session token sources, secure persistence, and failed-write session fallback.
src/credentials.ts Integrates static credentials and validates rotation candidates before persisting an active-account switch.
src/index.ts Adds token-management authentication methods and bounded API-request rotation while excluding long-context rate limits.
src/rotation.ts Implements cooldown parsing, bench persistence, account ordering, and candidate selection.
src/keychain.ts Combines discovered Claude accounts with static token accounts while preserving credential usability semantics.
src/logger.ts Extends value-based secret redaction to Anthropic token formats.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Credential sources] --> B[Build account roster]
  B --> C[Select active healthy account]
  C --> D[Send Anthropic API request]
  D -->|Success| E[Clear account bench]
  D -->|Rate limited| F[Bench current account]
  F --> G{Usable untried candidate?}
  G -->|Yes| H[Load candidate credentials]
  H -->|Usable| I[Persist active source]
  I --> D
  H -->|Unusable| G
  G -->|No| J[Restore prior source and return rate limit]
Loading

Reviews (2): Last reviewed commit: "fix: address review findings on token pe..." | Re-trigger Greptile

Comment thread src/index.ts
Comment thread src/credentials.ts Outdated
Comment thread src/token-store.ts Outdated
- Tokens accepted when the store write fails are held in memory for the
  session, instead of being silently dropped while the flow reported
  they applied to this session.
- Rotation persists a target only after it produces usable credentials,
  skipping candidates that cannot and restoring the previous account
  when none can. Committing first stranded the session, and later ones
  via the state file, on a broken account.
- Token and rotation state files are written with exclusive creation, so
  an env-configured path in a shared directory cannot be redirected
  through a pre-created symlink.
@psalkowski

Copy link
Copy Markdown
Author

Thanks — all three P1s were valid. Fixed in 313b04f.

1. Unpersisted tokens are discarded (src/index.ts)

Correct, and the flow actively lied about it: it reported "tokens apply to this session only" while refreshAccountsList() rebuilt the roster from the unchanged file, so the token applied to nothing.

Fixed by giving the store a session-only tier. Tokens accepted when the write fails are held in memory and surfaced by listTokenEntries(), so they behave exactly like OPENCODE_CLAUDE_AUTH_TOKENS entries — usable now, gone on restart, which is what the message promises.

2. Unusable rotation target is committed (src/credentials.ts)

Also correct, and worse than described: the account was persisted, so the strand outlived the process. Rotating off a rate-limited-but-authenticating account onto a broken one is a net loss.

Candidate selection is now a loop. Each candidate is made active only to probe it — getCachedCredentials() resolves through the active account, so that ordering is forced — and saveAccountSource() runs only once credentials come back usable. Unusable candidates are skipped in turn; if none work, the previous active source is restored and nothing is written.

3. Temporary write follows symlinks (src/token-store.ts)

Valid. It needs the configured path to sit in a directory another local user can write, so it's misconfiguration-gated rather than reachable by default — but the payload is a bearer token, and the fix is three lines, so it's worth closing regardless.

Both state writers now use openSync(tmp, "wx", 0o600) (O_CREAT|O_EXCL), which refuses to follow a pre-existing symlink, with the pid in the temp name so concurrent writers don't collide. I applied the same fix to writeRotationState in src/rotation.ts, which had the identical pattern — not flagged, but it would let an attacker-chosen file be clobbered even though its contents aren't secret.

Testing

make all green, 425/425 (up from 419). Six new tests, and I verified each one fails against the pre-fix code rather than assuming it pinned the behaviour:

  • session-token fallback: token resolvable through listTokenEntries / readStaticCredentials / readTokenAccounts after a failed write; no duplicate on re-add; gone after a simulated restart
  • symlink refusal: plants symlinks at both the legacy and pid-qualified temp paths, asserts the write fails and the target file is neither overwritten nor given a token
  • rotation validation: an unusable candidate placed between the limited account and a working one is skipped, and only the working account is persisted; when no candidate is usable, the persisted account is left untouched

One note for anyone else running the suite locally: ANTHROPIC_CLI_VERSION overrides version in billing header fails when CLAUDE_CODE_ENTRYPOINT is set in the shell, since transforms.ts:358 reads it and defaults to sdk-cli. It passes unset and on unmodified main, so it's unrelated to this PR — happy to add an env guard separately if you want it.

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.

1 participant