Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [5.0.1] — 2026-08-10

### Fixed
- **The subscription login did not survive a restart.** The credential was stored correctly — in the IDE's
PasswordSafe, which resolves to the OS store — KWallet or GNOME Keyring through the Secret Service on
Linux, the Keychain on macOS, the Credential Manager on Windows — and it was still there after the reboot,
confirmed by reading the entry back out of the OS store directly. What expired was the *access
token* inside it: the OAuth flow issues one good for hours (~10 h, measured), so any restart the next day
found a perfectly persisted credential that no longer authenticated anything. `hasUsableToken()` answered
false, and false meant "signed out", so the sign-in card came back every morning.

The blob beside it always carried a **refresh token valid for weeks** and the plugin never spent it, by
design: only the binary can, and it does so by rewriting `~/.claude/.credentials.json` — the exact file the
vault exists to remove. The way out is that the binary has a **non-interactive** login for precisely this:
given `CLAUDE_CODE_OAUTH_REFRESH_TOKEN` and `CLAUDE_CODE_OAUTH_SCOPES`, `claude auth login` takes a
dedicated branch, mints a fresh credential and exits — no browser, no TTY, no user. So renewal is now the
binary's job, exactly as it always was, and the plugin's job stays what it was: take custody of the result
and delete the plaintext copy. No OAuth client here, no token endpoint called from the IDE, no file written
back — the invariant `NoFileDeletionContractTest` and the vault's KDoc both state is untouched.

Reported on **Linux and Windows**, and it is one bug rather than two: the binary's default credential store
is its `plaintext` provider (`~/.claude/.credentials.json`) on every platform, so the vault takes custody
the same way everywhere and the token expires the same way everywhere. The fix carries 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.

**Scope: the subscription (OAuth) credential only.** An Anthropic API key is a different identity in a
different slot — `providerApiKey:anthropic` in the same PasswordSafe, not `CLAUDE_CREDENTIALS_JSON` — and it
has 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, so an API-key session is untouched by any of this.

An expired-but-renewable credential now counts as an identity (`CredentialsVault.canRenew`), the renewal
runs off the EDT at launch (`ClaudeSession.renewVaultedCredential`, before the launch 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 failed renewal arms a five-minute cooldown so the three-second boot watcher cannot turn
a flaky network into a process spawn per poll. Sign-in is now needed only after a genuinely idle period, or
when Anthropic invalidates the grant.

## [5.0.0] — 2026-08-05

The standards-compliance major. The repository was taken through the standards catalogue domain by domain —
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
## v5.0.1 — 2026-08-10

**You should stop having to sign in every morning.** Your login was being stored properly all along — in your
OS credential store, through the IDE's own password safe (KWallet or GNOME Keyring on Linux, Keychain on
macOS, Credential Manager on Windows). What was expiring was the token inside it: Claude issues one that lasts
hours, so a restart the next day found a credential that had gone stale, and the plugin asked you to sign in
again rather than renewing it.

It renews it now. The longer-lived half of your credential — the part good for weeks, and refreshed every time
it's used — is handed back to the `claude` binary, which mints a new token without a browser, without a
terminal and without you. Nothing about how it's stored changes: the credential still lives encrypted in your
OS store and never sits in plaintext on disk. In practice you'll now only be asked to sign in after a long
idle period, or if Anthropic invalidates the session.

**Which sign-in this is about:** the **subscription** one (Claude Pro or Max — the *Sign in* button and the
account row in the dashboard). That is the credential that carries a token with an expiry date on it. If you
authenticate with an **Anthropic API key** instead, nothing here changes for you and nothing here was broken
for you: an API key does not expire and has nothing to renew, it is kept in the same OS-backed store, and it
already survived restarts.

## v5.0.0 — 2026-08-05

**Nothing you use changes.** This is a major because the *project* changed, not the product: the whole
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ plugins {
}

group = "dev.lain"
version = "5.0.0"
version = "5.0.1"

repositories {
mavenCentral()
Expand Down
37 changes: 35 additions & 2 deletions src/main/kotlin/dev/lain/claudejb/process/AuthCli.kt
Original file line number Diff line number Diff line change
Expand Up @@ -95,20 +95,53 @@ object AuthCli {
fun logout(binary: File, env: Map<String, String>): Boolean =
run(binary, env, "auth", "logout") != null

/**
* **Non-interactive** `claude auth login`, driven entirely by a refresh token in the environment — no
* browser, no TTY, no user.
*
* This is a first-class path in the binary, not a trick: given `CLAUDE_CODE_OAUTH_REFRESH_TOKEN` the
* command takes a dedicated branch (`tengu_login_from_refresh_token`), exchanges the token at
* `platform.claude.com/v1/oauth/token` and stores the result in its own credential store, then exits 0.
* `CLAUDE_CODE_OAUTH_SCOPES` accompanies it and is always sent: the binary carries an explicit refusal
* for the case where it is missing ("required when using CLAUDE_CODE_OAUTH_REFRESH_TOKEN", naming the
* space-separated scopes it wants), and the grant cannot be restated without it — so
* [dev.lain.claudejb.process.CredentialsVault.renew] will not attempt a renewal from a blob that carries
* no scopes. Verified against `claude` 2.1.223 that the branch is taken and is genuinely non-interactive:
* with a deliberately invalid refresh token it fails on the HTTP round-trip and exits 1 without opening
* a browser or waiting on a terminal.
*
* That is what makes the vaulted login survive a reboot: the access token lives hours, the refresh token
* lives weeks, and this is the plugin's way of spending the second to mint the first WITHOUT holding an
* OAuth client itself. Not "the host refreshes the token" — the binary does, exactly as it always has.
*
* Its own 30 s HTTP timeout sits under this one, hence the longer wait: a renewal killed at 15 s would be
* reported as a failed login when it was merely a slow network.
*/
fun loginFromRefreshToken(binary: File, env: Map<String, String>): Boolean =
run(binary, env, "auth", "login", timeoutMs = LOGIN_TIMEOUT_MS) != null

/** Runs the binary with [args] and the given env; null on spawn failure, timeout or non-zero exit. */
private fun run(binary: File, env: Map<String, String>, vararg args: String): String? {
private fun run(
binary: File,
env: Map<String, String>,
vararg args: String,
timeoutMs: Int = TIMEOUT_MS,
): String? {
val output = runCatching {
val cmd = GeneralCommandLine(listOf(binary.absolutePath) + args)
.withEnvironment(env)
.withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE)
// destroyOnTimeout: a binary that never answers must not outlive the question. Without it the
// timeout only stops us WAITING — the process and its stream readers stay alive, which surfaced as
// a leaked-thread failure attributed to whichever test ran next.
CapturingProcessHandler(cmd).runProcess(TIMEOUT_MS, true)
CapturingProcessHandler(cmd).runProcess(timeoutMs, true)
}.getOrNull() ?: return null
if (output.isTimeout || output.exitCode != 0) return null
return output.stdout
}

private const val TIMEOUT_MS = 15_000

/** A renewal is a network round-trip with a 30 s timeout of its own; 15 s would cut it short. */
private const val LOGIN_TIMEOUT_MS = 60_000
}
98 changes: 90 additions & 8 deletions src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,16 @@ import java.io.File
* delete in the (now removed) session config dir followed symlinks into `~/.claude` and destroyed a user's
* conversations, skills and session history. Nothing else on their disk is ours to remove.
*
* The cost is stated rather than hidden: only the binary can spend the refresh token, and it does that by
* rewriting its own file. With no file it cannot, so when the access token expires the credential is simply
* spent and the sign-in card comes back. A periodic sign-in is the price of never having a bearer token
* sitting in a world-readable-by-the-user file.
* **Expiry is handled by renewal, not by asking the user again** ([renew]). The access token lives hours —
* measured at ~10 h on a fresh `auth login` — so with nothing but the token in the safe the identity died
* overnight and the sign-in card was back after every reboot: the credential persisted perfectly and simply
* expired. Only the binary can spend a refresh token, and that stays true here; the plugin does not hold an
* OAuth client, does not talk to the token endpoint and does not write the file back. It runs the binary's
* own non-interactive `auth login` with the vaulted refresh token in the environment
* ([AuthCli.loginFromRefreshToken]), lets it mint and store a fresh credential, and harvests that the same
* way it harvests any other login. The refresh token (weeks, and rotated at every renewal) becomes the thing
* that survives a restart, and the plaintext file exists only for the moment between the binary writing it
* and [harvest] taking it away.
*/
object CredentialsVault {

Expand All @@ -60,6 +66,9 @@ object CredentialsVault {
*/
private const val EXPIRY_MARGIN_MS = 10 * 60 * 1000L

/** How long a failed renewal stops us trying again — the boot watcher polls every few seconds. */
private const val RENEW_COOLDOWN_MS = 5 * 60 * 1000L

// The rest of the credential's env surface. Verified present in the shipped CLI's own env registry
// (`sdk.mjs`/`bridge.mjs` name them, and sdk.mjs lists the OAuth ones in its subprocess passthrough).
// `SecretStore.OAUTH_TOKEN` carries the access token itself.
Expand Down Expand Up @@ -171,14 +180,87 @@ object CredentialsVault {
}

/**
* Whether the vault holds a subscription credential that can still authenticate a session.
* Whether the vault holds an access token that can authenticate a session **right now**.
*
* An EXPIRED blob deliberately answers false: it cannot be refreshed without writing the file back, so
* it is not an identity any more. Callers treat that as signed-out and show the card, which beats
* launching a session that will fail its first turn.
* An expired blob answers false — but that is no longer the end of the identity: see [canRenew], which
* asks the second question ("can we mint a new one?"). Callers wanting "is there an identity at all"
* must consider both, or they will show a sign-in card to a user whose credential only needed renewing.
*/
fun hasUsableToken(): Boolean = usableToken() != null

/**
* Whether the vaulted blob can be turned back into a live access token without the user.
*
* Three conditions, all from the blob itself: a refresh token, the scopes it was issued with (the
* non-interactive path asks for them and the grant cannot be restated without them — see
* [AuthCli.loginFromRefreshToken]), and a
* `refreshTokenExpiresAt` that is still in the future. A blob with no expiry recorded is given the
* benefit of the doubt: the endpoint is the authority on that, and a wrong guess here costs one failed
* renewal, while refusing costs a sign-in the user did not need.
*
* Also false during the cooldown a failed renewal sets, so a caller that polls every few seconds cannot
* turn a transient network failure into a process spawn every few seconds.
*/
fun canRenew(): Boolean {
if (System.currentTimeMillis() < renewBlockedUntil) return false
val oauth = oauthNode() ?: return false
if (oauth.string("refreshToken") == null) return false
if (oauth.strings("scopes").isNullOrEmpty()) return false
val expiresAt = oauth["refreshTokenExpiresAt"]?.jsonPrimitive?.longOrNull ?: return true
return expiresAt - System.currentTimeMillis() > EXPIRY_MARGIN_MS
}

/** An identity that exists but is not usable as it stands — exactly the case [renew] exists for. */
fun needsRenewal(): Boolean = usableToken() == null && canRenew()

/**
* Mints a fresh credential from the vaulted refresh token, by running the binary's own non-interactive
* `auth login` ([AuthCli.loginFromRefreshToken]) and taking custody of what it writes.
*
* BLOCKING — it spawns a process and makes a network call. Pooled thread only, and never while a
* [dev.lain.claudejb.session.LoginCoordinator] sign-in is in flight: both write the same file, and the
* caller owns that guard.
*
* The order after a successful login is the same one every other credential path here follows, for the
* same reason: [AccountProfile.capture] asks `~/.claude.json` WHO this is while the login is freshest,
* then [harvest] takes the credential off the disk. Reversed, the question can still be answered — but a
* renewal is also the moment the account object is rewritten, so capturing here keeps the dashboard's
* identity from ageing out with the token that carried it.
*
* A failure of any leg (login, harvest, or a harvested blob that still is not usable) arms a cooldown
* and answers false; the caller then falls back to whatever other identity exists, and ultimately to the
* sign-in card. Nothing is cleared: a transient failure must not destroy a refresh token that is still
* perfectly good for the next attempt.
*
* @param baseEnv the RAW settings env. Deliberately not the launch env — handing the binary the expired
* access token we are trying to replace is at best noise and at worst the thing it authenticates with.
*/
fun renew(binary: File, baseEnv: Map<String, String>): Boolean {
if (inertHere()) return false
val oauth = oauthNode() ?: return false
val refreshToken = oauth.string("refreshToken") ?: return false
val scopes = oauth.strings("scopes")?.takeIf { it.isNotEmpty() } ?: return false
// Case-insensitively: environment names are case-insensitive on Windows, so a hand-written
// `Claude_Code_Oauth_Token` in Settings would survive an exact-match removal and then be the very
// expired token the renewal is trying to replace.
val env = baseEnv.filterKeys { !it.equals(SecretStore.OAUTH_TOKEN, ignoreCase = true) } + mapOf(
ENV_REFRESH_TOKEN to refreshToken,
ENV_SCOPES to scopes.joinToString(" "),
)
val renewed = AuthCli.loginFromRefreshToken(binary, env) && run {
AccountProfile.capture()
harvest()
hasUsableToken()
}
if (!renewed) log.warn("could not renew the vaulted credential from its refresh token")
renewBlockedUntil = if (renewed) 0L else System.currentTimeMillis() + RENEW_COOLDOWN_MS
return renewed
}

/** Set by a failed [renew]; see [canRenew]. */
@Volatile
private var renewBlockedUntil = 0L

/**
* The plan name recorded in the vaulted blob (`max`, `pro`, …), or null.
*
Expand Down
17 changes: 1 addition & 16 deletions src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt
Original file line number Diff line number Diff line change
Expand Up @@ -287,22 +287,7 @@ data class RateLimitInfo(
val overageInUse: Boolean = false,
val surpassedThreshold: Double? = null,
) {
/**
* Clamped 0..100 percent, or null if the binary didn't report utilization.
*
* **The event's scale is a 0..1 FRACTION, and it is not the same as `get_usage`'s.** Captured live from
* `claude` 2.1.223 while claude.ai reported 92% of the weekly window spent:
*
* ```
* {"status":"allowed_warning","rateLimitType":"seven_day","utilization":0.92,"surpassedThreshold":0.75}
* ```
*
* `surpassedThreshold: 0.75` is the corroborating detail — thresholds are announced at 75%/85%, so the
* companion field is unambiguously a fraction too. `sdk.d.ts` documents "Percentage of the window used,
* 0-100" ONLY on the `get_usage` windows ([UsageWindow]); `SDKRateLimitInfo.utilization` carries no
* such note, and the two really do differ. Reading the event on the 0..100 scale rendered a window at
* 92% as **1%** — a quota bar that is not merely wrong but reassuring while the limit is about to hit.
*/
/** Clamped 0..100 percent, or null if the binary didn't report utilization. */
fun utilizationPercent(): Int? =
utilization?.let { Math.round(it * PERCENT).toInt().coerceIn(0, 100) }

Expand Down
Loading