diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cbc278..5ac8459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to CodexBuddyMac are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.1.1] — 2026-06-26 + +### Fixed + +- **Actionable message when the Claude session must be re-authenticated.** When + the stored Claude refresh token is permanently invalid (revoked, rotated away, + or expired — the OAuth endpoint returns `invalid_grant`), **Refresh Claude + Login** now reports *Claude session expired. Run `claude auth login` to sign in + again* instead of a generic "backing off after a recent failure". The bridge no + longer starts a five-minute retry backoff for this permanent failure and skips + further refresh calls against the dead token, so a fresh `claude auth login` is + picked up immediately. The re-auth state self-heals once a new credential is + stored, and `GET /claude/status` exposes a `reauth_required` flag. + ## [1.1] — 2026-06-19 ### Added diff --git a/CodexBuddyMac/CodexBuddyMac.xcodeproj/project.pbxproj b/CodexBuddyMac/CodexBuddyMac.xcodeproj/project.pbxproj index c612e80..7cebaca 100644 --- a/CodexBuddyMac/CodexBuddyMac.xcodeproj/project.pbxproj +++ b/CodexBuddyMac/CodexBuddyMac.xcodeproj/project.pbxproj @@ -260,7 +260,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -270,7 +270,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.example.CodexBuddyMac; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -290,7 +290,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -300,7 +300,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.example.CodexBuddyMac; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/CodexBuddyMac/CodexBuddyMac/Bridge/codex_usage_server.py b/CodexBuddyMac/CodexBuddyMac/Bridge/codex_usage_server.py index 27acc5e..b523eab 100755 --- a/CodexBuddyMac/CodexBuddyMac/Bridge/codex_usage_server.py +++ b/CodexBuddyMac/CodexBuddyMac/Bridge/codex_usage_server.py @@ -48,6 +48,13 @@ CLAUDE_REFRESH_BACKOFF_SECONDS = 300.0 _claude_refresh_lock = threading.Lock() _claude_refresh_retry_after: float = 0.0 +# A refresh token rejected with `invalid_grant` is permanently dead (revoked, +# rotated away, or expired); waiting cannot revive it, so the user must sign in +# again. Remember the dead token so we stop calling the endpoint with it and can +# tell the UI to prompt for re-authentication until a different credential +# appears. State self-heals once a new credential is stored. +_claude_reauth_required = False +_claude_dead_refresh_token: str | None = None # The /claude/refresh endpoint changes state (rotates the token, writes the # Keychain). Gate it behind a loopback token so a stray local process or a @@ -409,6 +416,7 @@ def _keychain_account() -> str | None: def claude_token_status() -> dict[str, Any]: """Report Claude token validity for the Settings UI (no secrets returned).""" + global _claude_reauth_required, _claude_dead_refresh_token creds = _read_keychain_credentials() if creds is None: return {"present": False, "valid": False, "can_refresh": False, @@ -417,16 +425,27 @@ def claude_token_status() -> dict[str, Any]: if not isinstance(oauth, dict) or not oauth.get("accessToken"): return {"present": False, "valid": False, "can_refresh": False, "message": "No Claude access token in credential"} + # A newly stored credential (different refresh token) clears a prior + # invalid_grant, so re-auth state self-heals after `claude auth login`. + if (_claude_dead_refresh_token is not None + and oauth.get("refreshToken") != _claude_dead_refresh_token): + _claude_reauth_required = False + _claude_dead_refresh_token = None can_refresh = bool(oauth.get("refreshToken")) expires_at = oauth.get("expiresAt") if isinstance(expires_at, (int, float)): seconds = (expires_at - time.time() * 1000) / 1000 valid = seconds > 0 + if valid: + _claude_reauth_required = False + _claude_dead_refresh_token = None return {"present": True, "valid": valid, "can_refresh": can_refresh, + "reauth_required": _claude_reauth_required, "expires_in_seconds": int(seconds), "refresh_in_seconds": int(seconds - CLAUDE_TOKEN_REFRESH_SKEW_SECONDS), "message": "Valid" if valid else "Expired"} return {"present": True, "valid": True, "can_refresh": can_refresh, + "reauth_required": False, "message": "Valid (no expiry recorded)"} @@ -438,7 +457,7 @@ def refresh_claude_token(expected_access_token: str | None = None) -> dict[str, post-write read-back doesn't verify, so a bad write can't corrupt the shared item (which also holds the mcpOAuth plugin tokens). """ - global _claude_refresh_retry_after + global _claude_refresh_retry_after, _claude_reauth_required, _claude_dead_refresh_token with _claude_refresh_lock: now = time.monotonic() @@ -452,6 +471,13 @@ def refresh_claude_token(expected_access_token: str | None = None) -> dict[str, oauth = data.get("claudeAiOauth") if not isinstance(oauth, dict) or not oauth.get("refreshToken"): return {"ok": False, "error": "No refresh token available"} + # A token already rejected with invalid_grant won't come back to life; + # skip the network call and ask for re-auth until a new credential is + # stored (detected by a different refresh token). + if (_claude_dead_refresh_token is not None + and oauth["refreshToken"] == _claude_dead_refresh_token): + return {"ok": False, "reauth_required": True, + "error": "Claude session expired. Run `claude auth login` to sign in again."} if expected_access_token is not None and oauth.get("accessToken") != expected_access_token: return {"ok": True, "message": "Claude token already refreshed by another request", "expires_in_seconds": None, "rotated_refresh_token": False} @@ -475,12 +501,20 @@ def refresh_claude_token(expected_access_token: str | None = None) -> dict[str, with urlopen(request, timeout=15, context=trusted_ssl_context()) as response: token_data = json.loads(response.read().decode("utf-8")) except HTTPError as exc: - _claude_refresh_retry_after = now + CLAUDE_REFRESH_BACKOFF_SECONDS detail = "" try: detail = exc.read().decode("utf-8")[:200] except OSError: pass + if exc.code == 400 and "invalid_grant" in detail: + # Refresh token is permanently dead. Don't start a transient + # backoff (waiting can't fix it); remember the dead token and + # flag re-auth so the next refresh is skipped until sign-in. + _claude_reauth_required = True + _claude_dead_refresh_token = oauth["refreshToken"] + return {"ok": False, "reauth_required": True, + "error": "Claude session expired. Run `claude auth login` to sign in again."} + _claude_refresh_retry_after = now + CLAUDE_REFRESH_BACKOFF_SECONDS return {"ok": False, "error": f"Refresh failed: HTTP {exc.code} {detail}".strip()} except (URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError) as exc: _claude_refresh_retry_after = now + CLAUDE_REFRESH_BACKOFF_SECONDS @@ -527,6 +561,8 @@ def refresh_claude_token(expected_access_token: str | None = None) -> dict[str, return {"ok": False, "error": "Write verification failed; restored original credential"} _claude_refresh_retry_after = 0.0 + _claude_reauth_required = False + _claude_dead_refresh_token = None return {"ok": True, "message": "Claude token refreshed", "expires_in_seconds": int(expires_in) if isinstance(expires_in, (int, float)) else None, diff --git a/CodexBuddyMac/CodexBuddyMac/BridgeService.swift b/CodexBuddyMac/CodexBuddyMac/BridgeService.swift index 9744cec..f7cca36 100644 --- a/CodexBuddyMac/CodexBuddyMac/BridgeService.swift +++ b/CodexBuddyMac/CodexBuddyMac/BridgeService.swift @@ -400,6 +400,16 @@ enum BridgeService { restartRecommended: true ) } + // A permanently dead refresh token (OAuth invalid_grant) cannot be + // renewed by the app or a bridge restart; the user must sign in again. + if let reauth = obj?["reauth_required"] as? Bool, reauth { + return ClaudeLoginRefreshResult( + succeeded: false, + message: (obj?["error"] as? String) + ?? "Claude session expired. Run `claude auth login` in Terminal to sign in again.", + restartRecommended: false + ) + } return ClaudeLoginRefreshResult( succeeded: false, message: (obj?["error"] as? String) ?? "Claude login refresh failed (HTTP \(code)).", diff --git a/tests/test_claude_auth_recovery.py b/tests/test_claude_auth_recovery.py index f7ad0e7..a1e17bf 100644 --- a/tests/test_claude_auth_recovery.py +++ b/tests/test_claude_auth_recovery.py @@ -1,5 +1,6 @@ import importlib.util import copy +import io import json import os import tempfile @@ -199,6 +200,54 @@ def test_bridge_auth_token_file_is_private(self): self.assertTrue(token) self.assertEqual(os.stat(token_file).st_mode & 0o777, 0o600) + def test_invalid_grant_flags_reauth_and_skips_backoff(self): + bridge = load_bridge("bridge_invalid_grant_test") + credentials = { + "claudeAiOauth": { + "accessToken": "old-access", + "refreshToken": "dead-refresh", + "expiresAt": 0, + } + } + bridge._read_keychain_credentials = lambda: (json.dumps(credentials), credentials) + bridge._keychain_account = lambda: "test-account" + bridge.trusted_ssl_context = lambda: None + + calls = [] + + def fail(*args, **kwargs): + calls.append(1) + body = io.BytesIO( + b'{"error":"invalid_grant",' + b'"error_description":"Refresh token not found or invalid"}' + ) + raise HTTPError(bridge.CLAUDE_OAUTH_TOKEN_URL, 400, "Bad Request", Message(), body) + + bridge.urlopen = fail + + first = bridge.refresh_claude_token() + self.assertFalse(first["ok"]) + self.assertTrue(first.get("reauth_required")) + # A permanent failure must not start the transient retry backoff. + self.assertEqual(bridge._claude_refresh_retry_after, 0.0) + + # The dead token is remembered: a second attempt short-circuits without + # hitting the endpoint again. + second = bridge.refresh_claude_token() + self.assertTrue(second.get("reauth_required")) + self.assertEqual(len(calls), 1) + + # Status reports re-auth required while the dead token is still stored. + status = bridge.claude_token_status() + self.assertTrue(status.get("reauth_required")) + + # Storing a fresh credential clears the re-auth state. + credentials["claudeAiOauth"]["refreshToken"] = "new-refresh" + credentials["claudeAiOauth"]["expiresAt"] = (time.time() + 3600) * 1000 + healed = bridge.claude_token_status() + self.assertTrue(healed["valid"]) + self.assertFalse(healed.get("reauth_required")) + def test_token_status_reports_proactive_refresh_timing(self): bridge = load_bridge("bridge_token_refresh_timing_test") expires_at = (time.time() + 3600) * 1000