Skip to content

claude-code provider: no OAuth renewal — emulation depends on the real CLI keeping ~/.claude/.credentials.json fresh #1818

Closed

Description

@dezren39

Gap: claude-code provider does not perform the Claude Code binary's OAuth renewal

Provider: packages/ai/src/providers/claude-code-auth.ts (anomalyco#6117 native wire emulation)

What the binary does

The Claude Code CLI renews its claude.ai OAuth login itself: when the access token in ~/.claude/.credentials.json approaches/passes expiry, the CLI exchanges the stored refreshToken at Anthropic's OAuth token endpoint (POST https://console.anthropic.com/v1/oauth/token, grant_type=refresh_token, the Claude Code OAuth client identity) and writes the rotated credential back to the same file. Fleet-observed cadence: the file's expiresAt advances in ~8h steps as the CLI runs (e.g. 2026-11-06: 08:19 CST → 09:19 CST after CLI use).

What the emulation does

Read-only by deliberate design. claude-code-auth.ts lines 34–40 (header) state it plainly:

"The credential is read IN PLACE, fresh per request, never copied into the fork's credential store, never written back, and never logged. This provider deliberately does NOT refresh the token: the refresh token is the CLI's, a refresh here would rotate it out from under the CLI, and renewal of an expired login is the interactive claude sign-in. An expired credential therefore fails LOUDLY … instead of silently degrading."

parseCredentialsFile does not even read refreshToken out of the blob (only accessToken + expiresAt); an expired file credential falls through to CLAUDE_CODE_OAUTH_TOKEN if set (#1752), else fails with expiredMessage ("this provider does not refresh the CLI's login. Run claude…").

Why this is a gap vs the binary

  • The provider's liveness silently depends on the real CLI being run interactively often enough to keep the shared file fresh. A laptop where nobody runs claude for >8h has a dead claude-code provider even though a valid refresh token sits in the same file.
  • The in-repo precedent solves the identical problem safely: antigravity-auth.ts performs the CLI's own refresh exchange in memory only, noting Google's client does not rotate the refresh token, "so refreshing here never invalidates the CLI's stored credential". Anthropic's OAuth behavior for the Claude Code client needs the same determination: if the token endpoint does NOT rotate the refresh token (or the rotated pair can be written back to the credentials file atomically, mirroring the CLI), the emulation can renew without racing the CLI.
  • Quota side already half-knows: packages/ai/src/quota/providers/claude.ts parses refreshToken out of the same blob (line 79) but never uses it; on 401/403 it reports "Claude session expired. Open Claude Code to sign in again."

Suggested shape (mirrors antigravity-auth)

  1. Parse refreshToken in parseCredentialsFile.
  2. On expired (or near-expiry) file credential, attempt ONE in-memory refresh against the OAuth token endpoint with the Claude Code client identity; use the returned access token for the request; never log tokens.
  3. Decide write-back vs memory-only from Anthropic's actual rotation behavior (probe: refresh once, then check whether the CLI still functions with the file's original refresh token). If rotation invalidates the file's token, write the rotated pair back to ~/.claude/.credentials.json atomically (0600 preserved) exactly as the CLI would.
  4. Keep the fail-loud behavior when refresh itself fails.

Evidence gathered 2026-11-06 (fleet laptop)

  • Credential file: claudeAiOauth, scopes include user:inference + user:sessions:claude_code, refresh token present, expiresAt observed advancing after real-CLI runs.
  • The provider's inference wire shape is otherwise verified live (200s from api.anthropic.com/v1/messages in the provider's exact shape); renewal is the one lifecycle piece of the binary the emulation does not cover.

Acceptance criteria

  • The provider's behavior when the CLI credential passes expiry with no live CLI renewal is explicit and observable: either the emulation performs the refresh-token exchange itself (with the rotation hazard addressed in writing), or the failure surfaces as a named, actionable error (expired credential, run the CLI to renew) instead of an opaque auth failure.
  • The read-in-place, never-logged credential discipline is preserved by any change.
  • Coverage exists for the expired-credential path.

Activity

  1. dezren39 commented on Nov 6, 2026

    @dezren39
    Author

    🤖 Triage evidence · issue-sweep-rotating (Make feeder)

    • Checked: this issue against current main 22c0a6ddbee19878e8e51bde432e995f75ccdd2a (verified x2 via gh api), reading packages/ai/src/providers/claude-code-auth.ts and packages/ai/src/quota/providers/claude.ts raw via gh api contents.
    • Gap CONFIRMED still present: the emulation still performs no OAuth renewal:
      • claude-code-auth.ts:35-40 — header states it plainly: the credential is read in place, never written back, and "this provider deliberately does NOT refresh the token"
      • claude-code-auth.ts:76-94 — parseCredentialsFile destructures only accessToken + expiresAt (:86); the blob's refreshToken is never read
      • claude-code-auth.ts:53-55 / :144-146 — an expired file credential fails loudly with expiredMessage ("this provider does not refresh the CLI's login. Run claude…"), falling back only to CLAUDE_CODE_OAUTH_TOKEN when set
      • quota/providers/claude.ts:79 — the quota side already parses refreshToken out of the same blob but never uses it for renewal
      • So the provider's liveness still silently depends on the real CLI being run often enough (~8h expiresAt cadence per the issue's fleet observation) to keep ~/.claude/.credentials.json fresh, even though a valid refresh token sits in the same file.
    • What "done" looks like: first, the rotation determination the issue calls for (does Anthropic's OAuth endpoint for the Claude Code client rotate the refresh token, and if it does, can the rotated pair be written back atomically at 0600 exactly as the CLI would — that probe is execution work for the queue lane, not this feeder); then, mirroring antigravity-auth.ts's in-memory refresh precedent: parse refreshToken, attempt ONE in-memory refresh on an expired/near-expiry file credential, never log tokens, and keep the fail-loud behavior when refresh itself fails.
    • Next: enqueuing a repo-task/v1 triage packet into repo-health-opencode-pilot from this feeder run; the queue lane owns the probe and the fix work.
  2. dezren39 commented on Nov 6, 2026

    @dezren39
    Author

    🤖 Agent note · queued by triage feeder

    • Agent: issue sweep (issue-sweep-rotating, Make feeder)
    • Session / ID: issue-sweep-rotating run 2026-11-06 04:24 CST
    • Found via: feeder rotation (prev rotating line dezren39/darwin-nix 2026-11-06T09:27Z -> developing-today-forks/opencode)
    • Task: Enqueued repo-task/v1 triage packet as task 10990 in queue repo-health-opencode-pilot (kind triage, priority 40, dedupe repo-health:v1:triage:developing-today/opencode:issue:1818). Packet: hidden_files/packet-opencode-1818.json.
    • Evidence: feeder triage comment claude-code provider: no OAuth renewal — emulation depends on the real CLI keeping ~/.claude/.credentials.json fresh #1818 (comment) — gap CONFIRMED on main 22c0a6ddbee1 (verified x2): header claude-code-auth.ts:35-40 states the provider deliberately does not refresh; parseCredentialsFile (:86) never reads refreshToken; expired credentials fail loudly at :144-146.
  3. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    source/humanFiled directly by a human
    priority/lowNice to have; do when convenient
    effort/mMedium: expected under 1 day of work
    meta/enrichedIntake review done: acceptance criteria, links, and normalized labels added
    on Nov 6, 2026
  4. dezren39 commented on Nov 6, 2026

    @dezren39
    Author

    Intake review

    🤖 Fleet monitor intake (repo-fleet-monitor), 2026-11-06.

  5. added
    queue/claimedA queue worker has claimed the task for this item
    work/assignedAn agent holds this item via the queue (claimed task)
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 6, 2026
  6. dezren39 commented on Nov 6, 2026

    @dezren39
    Author

    Triage complete — queue task 10990 (lane opencode-a), verified on origin/main 22c0a6d.

    Current behavior (fresh file:line)

    Rotation determination — LIVE PROBE (the verdict this triage owed)

    Probe environment: fleet laptop, Claude Code 2.1.306, live ~/.claude/.credentials.json (0600; access token ~4.7h from expiry, refresh token present). Endpoint and client id taken from the installed CLI binary itself: POST https://platform.claude.com/v1/oauth/token, client_id 9d1c250a-e61b-44d9-88ed-5944d1962f5e, form-encoded grant_type=refresh_token (the endpoint rejects JSON with 400 "Invalid request format"; the response is snake_case).

    1. One refresh with the file's refresh token → 200, response carries a new refresh_token.
    2. Re-presenting the file's original refresh token → 400 invalid_grant ("Refresh token not found or invalid").

    Verdict: Anthropic rotates the Claude Code client's refresh token on every refresh — refresh tokens are single-use. The CLI does not still function on the file's original token after a provider-side refresh. This confirms the header's assertion and kills the memory-only route: the antigravity precedent does not transfer, because Google does not rotate that client's refresh token (antigravity-auth.ts header) and Anthropic demonstrably does.

    Probe side effect, disclosed: the probe's first scripted refresh succeeded server-side but its rotated result was not written back (a field-case bug in the probe script), so the probe host's stored login was burned — its CLI now reports "OAuth session expired and could not be refreshed" and awaits an interactive claude sign-in by Drew. That accident is itself the strongest design evidence for the fix: a refresh whose rotated result is dropped burns the login, so refresh and write-back must be one atomic operation with no discard path.

    Route decided

    Renewal = refresh + immediate atomic write-back, in this order, every time:

    Fix routed

    Fix packet enqueued as task 10996 (dedupe repo-health:v1:fix:developing-today/opencode:issue:1818). Routing note: the enqueue landed in the default repo-health-code-shadow queue rather than repo-health-opencode-pilot (the queue flag was applied only on the retry, which dedupe-matched the first task); the packet itself is the correct opencode packet and the task has already been claimed there. No duplicate was created.

  7. 13 remaining items

  8. removed
    work/assignedAn agent holds this item via the queue (claimed task)
    on Nov 8, 2026
  9. dezren39 commented on Nov 8, 2026

    @dezren39
    Author

    🤖 Agent note · parked — accidental cross-queue claim returned

    • Agent: queue worker (repo-worker-code), OpenChamber scheduled lane ec118cad-0fd3-4652-91c4-27432f2f2efd
    • Session / ID: this scheduled run; task 10996
    • Found via: accidental bare repo-queue claim without --queue; response showed this task belongs to repo-health-code-shadow, outside this lane’s repo-health-code-pilot scope
    • Action: no task work started. Immediately parked task 10996 back on its original shadow queue with a 120-second delay; this run will not claim it again. Existing renewal fix task 12945 in repo-health-opencode-pilot remains the canonical implementation route; no duplicate PR or fix was created.
    • Expectation: shadow-queue worker may resume after the short delay and revalidate against task 12945 before any work.
  10. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    work/assignedAn agent holds this item via the queue (claimed task)
    queue/claimedA queue worker has claimed the task for this item
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 8, 2026
  11. dezren39 commented on Nov 8, 2026

    @dezren39
    Author

    @/tmp/note.md

  12. removed
    queue/claimedA queue worker has claimed the task for this item
    work/assignedAn agent holds this item via the queue (claimed task)
    on Nov 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    effort/mMedium: expected under 1 day of workkind/bugSomething isn't working (prefixed form of the classic bug label)meta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedpriority/lowNice to have; do when convenientsource/humanFiled directly by a human

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions