anthropics / anthropics/claude-code
Remote Control auto-start reports "re-login required" when the OAuth refresh simply hasn't landed yet (credential still renewable)
- Vorherrschende Sprache
- Python
- Sterne
- 145k
- Forks
- 23.1k
- PR-Merge-Kennzahlen
- PR-Kennzahlen ausstehend
Beschreibung
## 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.
Beitragsleitfaden
Für dieses Repository ist kein Beitragsleitfaden indexiert
Rechercherichtung
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.
Vom Indexierungsmodell aus dem Issue-Text verfasst.
Bewertung
- Tech-Stack
- javascript
- Bereich
- authentication, cli
- Issue-Typ
- Bug
- Schwierigkeit
- 4/5
- Geschätzter Aufwand
- 3-5 Tage
- Aktivitätsstatus
- Aktiv
- Klarheit
- Größtenteils klar
- Anfängerfreundlichkeit
- 48/100