anthropics / anthropics/claude-code

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

オープン
#92,330 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る
area:auth bug has repro platform:windows
主要言語
Python
スター
145k
フォーク
23.1k
PR マージ指標
PR 指標を取得中

説明

## 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.

コントリビューションガイド

このリポジトリのコントリビューションガイドは索引されていません

調査の方向性

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.

索引モデルが issue の本文から書いたものです。

評価

技術スタック
javascript
領域
authentication, cli
issue の種類
バグ
難易度
4/5
見積もり時間
3〜5日
活発さ
活発
明瞭さ
おおむね明確
初心者へのやさしさ
48/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。