Skip to content

fix(auth): renew the vaulted login instead of asking again - #43

Merged
serialexperimentslainnnn merged 4 commits into
developfrom
bugfix/persitent-session
Aug 10, 2026
Merged

serialexperimentslainnnn merged 4 commits into
developfrom
bugfix/persitent-session

Conversation

@serialexperimentslainnnn

@serialexperimentslainnnn serialexperimentslainnnn commented Aug 10, 2026 •

Copy link
Copy Markdown
Owner

The bug

The subscription login (Claude Pro/Max — the Sign in button and the account row) did not survive a restart — reported on Linux and Windows — and the cause was not persistence. The credential is stored through the IDE's PasswordSafe and therefore lands in the OS store (KWallet / GNOME Keyring via the Secret Service, Keychain, Credential Manager), and it was still there after the reboot; confirmed by reading the entry straight out of the OS store.

What expired was the access token inside it. auth login issues one good for ~10 h, so any restart the next day found a perfectly persisted credential that authenticated nothing: hasUsableToken() answered false, and false meant signed out.

Scope: OAuth only, not API keys

An Anthropic API key is unaffected and was never affected. It is a different identity in a different slot — providerApiKey:anthropic in the same PasswordSafe, not CLAUDE_CREDENTIALS_JSON — with no expiry and no refresh token, so there was nothing to lose across a restart and there is nothing to renew now. CredentialsVault.renew() reads the claudeAiOauth blob and nothing else, and envOverlay withdraws entirely when an API key is present.

The fix

The blob always carried a refresh token good for weeks, and nothing was allowed to spend it — spending it means the binary rewriting ~/.claude/.credentials.json, the file the vault exists to remove.

The way out is in the binary and is a first-class path: with CLAUDE_CODE_OAUTH_REFRESH_TOKEN + CLAUDE_CODE_OAUTH_SCOPES in the environment, claude auth login takes a dedicated non-interactive branch (tengu_login_from_refresh_token → POST platform.claude.com/v1/oauth/token), mints a credential into its own store and exits. Verified against 2.1.223 that the branch is genuinely non-interactive: an invalid refresh token fails on the HTTP round-trip and exits 1, with no browser and no TTY wait.

So renewal is the binary's job, exactly as it always was, and the plugin's job stays what it was — capture the account while the config is freshest, harvest the credential off the disk, delete it. The plugin still holds no OAuth client, calls no token endpoint and never writes that file back; the vault's invariant is untouched, only its cost is gone.

One bug, not two

The binary's default credential store is its plaintext provider on every platform (the keychain prefetch is stubbed and the Windows-Credential-Manager flag does not replace it), so the vault path and the expiry are identical everywhere. No platform-specific code — the only Windows-specific care is that the renewal environment strips CLAUDE_CODE_OAUTH_TOKEN case-insensitively, since environment names are case-insensitive there.

Wiring

  • CredentialsVault.canRenew()/needsRenewal()/renew() + AuthCli.loginFromRefreshToken() (60 s timeout; the binary's own HTTP timeout is 30 s).
  • An expired-but-renewable blob counts as an identity, so hasCredential no longer reports signed out (it runs on the EDT, so it must not renew).
  • ClaudeSession.renewVaultedCredential runs the renewal off the EDT inside launch(), before the launch env is built, and never while a sign-in is in flight (both write the same file).
  • The refresh token rotates at every renewal, so ordinary use extends it indefinitely.
  • A failed renewal arms a 5-minute cooldown: the boot watcher polls every 3 s, and without it a flaky network becomes a process spawn per poll. The ownLoginCheckedAt ttl cache is dropped on failure, since a renewal can sign the binary in even when we fail to take custody.

Commits

Commit What
fix(auth): renew the vaulted login instead of asking again The fix above, plus the 5.0.1 bump and docs.
feat(ui): show plan-limit percentages with one decimal Usage windows and the extra-credit balance travel as Double and are formatted with toFixed(1) in the web app instead of being rounded to an Int in Kotlin. Removes JcefSessionData.pctOf and JcefState.normalizePercent — the clamping they performed goes with them, which is worth a reviewer's eye.
docs(release): say which sign-in the renewal fix covers The notes described the fix without naming the credential, leaving an API-key user unable to tell whether it concerned them.

Verification

Every gate in ci.yml, run locally (Static analysis, Dependency audit and Plugin verifier are skipped on a PR into develop by design, so they were run by hand):

Gate Result
test koverVerify ✅ 754 tests, 0 failures + coverage gates
detekt / spotlessCheck ✅
npm test (vitest) ✅ 104 tests
npm run lint / format:check ✅
npm audit --omit=dev --audit-level=low ✅ 0 vulnerabilities
verifyPlugin ✅ Compatible: IC-251, IC-252, IU-253, IU-261, IU-262
Artifact assertions ✅ 0 node_modules entries; META-INF/{LICENSE,THIRD-PARTY-NOTICES.md} present

Six new tests pin the renewal semantics in CredentialsVaultHeadlessTest, including the reboot case itself (expired access token + live refresh token = still an identity).

Version bumped to 5.0.1, with CHANGELOG.md, RELEASE_NOTES.md and CLAUDE.md updated.

🤖 Generated with Claude Code

Empty commit to open a fresh PR into main and drive release.yml once GitHub
Actions recovers from the outage. No code change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The subscription login did not survive a restart, on Linux and on Windows
alike, and the cause was not persistence. The credential is in the IDE
PasswordSafe and therefore in the OS store (KWallet or GNOME Keyring through
the Secret Service, Keychain, Credential Manager), and it was still there
after the reboot. What expired was the access token inside it - `auth login`
issues one good for about ten hours, so any restart the next day found a
perfect credential that authenticated nothing, `hasUsableToken()` answered
false, and false meant signed out.

The blob always carried a refresh token good for weeks, and nothing was
allowed to spend it, since spending it means the binary rewriting
`~/.claude/.credentials.json` - the file the vault exists to remove. The way
out is in the binary and is a first-class path. With the environment carrying
CLAUDE_CODE_OAUTH_REFRESH_TOKEN and CLAUDE_CODE_OAUTH_SCOPES, `claude auth
login` takes a dedicated non-interactive branch, mints a credential into its
own store and exits. Verified against 2.1.223 that the branch is genuinely
non-interactive - an invalid refresh token fails on the HTTP round-trip and
exits 1, with no browser and no TTY wait.

So renewal is the binary's job, exactly as it always was, and the plugin's job
stays what it was - capture the account while the config is freshest, harvest
the credential off the disk, delete it. The plugin still holds no OAuth
client, calls no token endpoint and never writes that file back.

This is one bug rather than two. The binary's default credential store is its
`plaintext` provider on every platform, so the vault path and the expiry are
identical everywhere. No platform-specific code was needed; the only
Windows-specific care is that the renewal environment strips
CLAUDE_CODE_OAUTH_TOKEN case-insensitively, since environment names are
case-insensitive there.

An expired-but-renewable blob now counts as an identity
(`CredentialsVault.canRenew`), the renewal runs off the EDT in `launch()`
before the env is built and never while a sign-in is in flight, the refresh
token rotates at every renewal so ordinary use extends it indefinitely, and a
failure arms a five-minute cooldown because the boot watcher polls every three
seconds. Also drops a leftover CC-TRACE prefix from a rate-limit debug log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The usage windows and the extra-credit balance were rounded to a whole number
on the Kotlin side before they ever reached the web app, so the dashboard bars
and the composer dots could only ever read as integers. The percentage now
travels as a Double and the front end formats it with `toFixed(1)`, which also
makes the locale question visible - whether the decimal separator renders as a
comma or a dot is now something the UI can be observed doing rather than
guessed at.

Removes `JcefSessionData.pctOf` and `JcefState.normalizePercent` along with it,
since neither has a caller once the rounding moves to the display layer. The
clamping they performed goes with them; the event-sourced windows are still
multiplied by 100 exactly as `RateLimitInfo.utilizationPercent` did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 5.0.1 notes described the fix without naming the credential it applies to,
which leaves an API-key user unable to tell whether it concerns them.

It does not. An Anthropic API key is a different identity in a different slot
(`providerApiKey:anthropic` in the same PasswordSafe, not
`CLAUDE_CREDENTIALS_JSON`), it has no expiry and no refresh token, so nothing
was lost across a restart and nothing is renewed now. `CredentialsVault.renew`
reads the `claudeAiOauth` blob and nothing else, and `envOverlay` withdraws
entirely when an API key is present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@serialexperimentslainnnn
serialexperimentslainnnn merged commit f010e31 into develop Aug 10, 2026
10 checks passed
@serialexperimentslainnnn
serialexperimentslainnnn deleted the bugfix/persitent-session branch August 10, 2026 19:59
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