anthropics / anthropics/claude-code

Remote Control auto-start reports "re-login required" when the OAuth refresh simply hasn't landed yet (credential still renewable)

Ouverte
#92,330 1 commentaire 0 réactions 0 personnes assignées Voir sur GitHub
area:auth bug has repro platform:windows
Langage dominant
Python
Étoiles
145k
Forks
23.1k
Métriques de merge des PR
Métriques de PR en attente

Description

## Summary

With `remoteControlAtStartup: true`, the first session started after the access token has expired (i.e. the first launch of the day) reliably prints:

> Remote Control disconnected — Claude.ai login expired — run /login to restore Remote Control

The login is **not** expired. The refresh token is valid for weeks. Running `/remote-control` in the same session, seconds later, connects successfully with the very same credentials — no `/login` needed.

The startup guard treats "the refresh has not completed yet" as "the credential cannot be refreshed", and prints the expensive remedy (interactive `/login`) instead of the cheap one (retry / `/remote-control`).

## Environment

- claude-code 2.1.260 (global npm install, `bin/claude.exe`)
- Windows 11 Home 10.0.26200
- Entrypoint: VS Code extension (also reproduces from the terminal)
- Account: Pro, scopes include `user:sessions:claude_code`

## Related issues — why this is not a duplicate

The same `[bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required)` line already appears in existing reports, but always with a **genuinely dead credential**:

- **#90688** (Windows/VS Code, forced `/login` after every sleep/wake) logs this exact line — `[bridge:sdk] State change: failed - /login`, `Remote Control auto-enable failed: Error: /login` — but there the refresh token had been revoked server-side (HTTP 400, refresh-token reuse detection). The `/login` was genuinely required.
- **#91708** (Windows/VS Code) documents concurrent processes racing on the OAuth refresh and invalidating the token family. Again a real credential death.
- **#88951** (macOS) is the same startup guard failing, but with OAuth genuinely unhealthy (a shadowing keychain entry) and no user-visible signal at all.

This report is the **inverse case**: the credential is perfectly healthy and renewable, and the guard declares it dead anyway. The proof is that `/remote-control`, run seconds later in the same session with the same credential file untouched, connects successfully.

That difference is the point. Today the message is **indistinguishable between the fatal case (#90688) and the benign one**, so a user cannot tell "your login is really gone" from "the refresh just hasn't landed yet, retry". Both print `run /login`, and in the benign case that advice costs an unnecessary interactive re-authentication every single morning. Fixing #90688 and #91708 removes some causes of a *real* refresh failure; it does not stop this guard from misreporting a transient one.

## The guard

From the 2.1.260 bundle (minified, names as shipped):

```js
await ys({credentials: F, storageV5: B}); // attempt the OAuth refresh
let n = E3t(); // re-read credentials.expiresAt
if (n !== null && n <= Date.now()) {
Yb("oauth_expired_unrefreshable",
"[bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required)");
G?.("failed", Hve, "auth"); // surfaces the "/login" banner
let d = n;
return await we((p) => ({...p,
bridgeOauthDeadExpiresAt: d,
bridgeOauthDeadFailCount: p.bridgeOauthDeadExpiresAt === d
? (p.bridgeOauthDeadFailCount ?? 0) + 1 : 1}), B), null;
}
```

`ys()` can return without having refreshed anything for purely transient reasons — contention on `~/.claude/.oauth_refresh.lock` (`OAuthRefreshLockContendedError`, stale window 60s) when more than one session starts at once, or a slow/unavailable token endpoint at boot. The guard cannot tell those apart from a genuinely dead credential: it only observes that `expiresAt` is still in the past.

Note that #90688's logs show the very next line after this guard is `[bridge:sdk] State change: failed - /login` and `Remote Control auto-enable failed: Error: /login`, i.e. the `"auth"` classification passed to `G?.()` is what turns a possibly-transient condition into a terminal, login-demanding state for the whole session.

## Why this is a misdiagnosis, not a real auth failure

The predicate needed to distinguish the two cases is defined immediately adjacent to the one the guard uses, and is never consulted here:

```js
function E3t(){ return qt()?.expiresAt ?? null } // used by the guard
function pUe(){ return qt()?.subscriptionType ?? null }
function FAn(){ return qt()?.refreshToken != null } // never consulted by the guard
```

Concrete data from an affected machine:

```
.claude.json bridgeOauthDeadExpiresAt = 1788573991415 -> 2026-09-05 04:06:31 (recorded dead)
.credentials.json accessToken expiresAt = 1788642627089 -> 2026-09-05 23:10:27
refreshToken expiresAt = 1791042620089 -> 2026-10-03 17:50:20 (valid for 4 more weeks)
```

The access token has an ~8h TTL and only gets refreshed while Claude Code is running, so it always goes stale overnight. Every morning the bridge races its own refresh, loses, and declares the login dead — while the refresh token is nowhere near expiry.

## The same bridge already distinguishes these cases elsewhere

The proactive refresh cycle classifies the outcome three ways:

```js
C = { leg: "bad", code: Xe ? "oauth_rejected_after_refresh"
: re ? "oauth_rejected_refresh_failed"
: "oauth_rejected_no_refresh_path" };
```

`oauth_rejected_no_refresh_path` is precisely "there is no way to renew this". The startup guard collapses all three into `oauth_expired_unrefreshable`, which is why the wrong message reaches the user. This inconsistency inside the same component suggests an oversight rather than a deliberate choice.

## Why it costs users repeated logins

`/login` mints fresh tokens, so the bridge starts and the banner goes away — the wrong remedy appears to work. Users conclude that Remote Control genuinely requires a daily interactive re-login, and keep paying for it, when a retry would have sufficed.

## The fail-count backoff does not mitigate it

```js
if (e.bridgeOauthDeadExpiresAt != null && (e.bridgeOauthDeadFailCount ?? 0) >= 3
&& E3t() === e.bridgeOauthDeadExpiresAt) return skip;
```

The counter increments only when the *same* `expiresAt` fails again (`p.bridgeOauthDeadExpiresAt === d ? count+1 : 1`). Each morning brings a new stale expiry, so it resets to 1 and the banner repeats indefinitely. It only reaches 3 if several sessions are launched in the same morning — in which case Remote Control is skipped silently, with no banner at all, which is arguably a second problem.

## Steps to reproduce

1. Set `remoteControlAtStartup: true` at user scope.
2. Use Claude Code, then leave it closed for more than 8 hours (overnight), so the access token expires while nothing is running.
3. Start a session (starting two at once makes it far more likely, via the refresh lock).
4. The banner appears, pointing at `/login`.
5. Type `/remote-control` in that same session — it connects, with the credentials that were just declared dead.

## Suggested fix

In the startup guard, before reporting an auth failure, check whether a refresh path exists — `FAn()` / a non-expired `refreshToken`:

- **Refresh token present and unexpired** → transient. Do not surface an auth failure, do not record `bridgeOauthDead*`. Retry on the existing schedule, or fall through to the same path `/remote-control` uses (which already succeeds at this point).
- **No refresh token, or refresh token expired** → the current behaviour is correct; `/login` is genuinely required.

Distinguishing lock contention (`OAuthRefreshLockContendedError`) from a rejected refresh inside `ys()` would make this exact, and would also fix the case of several sessions starting simultaneously.

If retaining a user-visible message in the transient case is preferred, "Remote Control couldn't start yet — run /remote-control to connect" would point at the remedy that actually works.

Guide de contribution

Aucun guide de contribution indexé pour ce dépôt

Piste de recherche

Start at the shipped bundle behind bin/claude.exe and trace the startup guard around ys(), E3t(), and FAn(). Compare its handling with /remote-control and the proactive refresh cycle; done means transient refresh contention no longer produces the auth failure/banner or bridgeOauthDead* state, while genuinely expired refresh credentials still request /login. No test file is named.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
javascript
Domaine
authentication, cli
Type d'issue
Bug
Difficulté
4/5
Temps estimé
3-5 jours
Activité
Active
Clarté
Plutôt claire
Accessibilité débutants
48/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.