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

Status Open
Reported on v2.1.260
Maintainer reply None cached
Activity 2 comments · opened Sep 5, 2026

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):

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:

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:

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

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.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗