Fixes #25628

AU
4d ago
+976-66

Summary

Fixes #25628. Signed-in macOS users (classic and NEXT both use CmuxAuthRuntime and the vendored Stack SDK) could lose a valid session for reasons unrelated to the session itself:

  1. Token refresh deleted the session on any 400/401. Every launch after an update refreshes the expired access token. The SDK treated every 400/401 from /api/v1/auth/oauth/token as a dead refresh token. Stack sends those same statuses when it rejects the client or the request (INVALID_PUBLISHABLE_CLIENT_KEY, PUBLISHABLE_CLIENT_KEY_REQUIRED_FOR_PROJECT, INVALID_OAUTH_CLIENT_ID_OR_SECRET, schema errors, OAuth invalid_client / invalid_request). Refresh rejection is now classified inside APIClient:
    • A rejection that names the refresh token (REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED, REFRESH_TOKEN_ERROR, PROVIDER_REJECTED, invalid_grant), or a Stack answer that names no error, still clears the session.
    • A named client rejection, or a page that isn't Stack's (non-JSON, including non-UTF-8), keeps the session.
    • A kept rejection starts a cancellation-aware cooldown, which resets when the refresh-token session changes.
  2. A failed Keychain read counted as signed out. macOS passed no isTokenStorageAvailable to the coordinator, so it always reported true. It now passes a probe from FallbackTokenStore / KeychainStackTokenStore.
    • Only temporary failures (errSecInteractionNotAllowed, errSecNotAvailable, on the current or the legacy account) count as unavailable, and they preserve the cached session.
    • A permanent failure reads as an empty store. After the file fallback is checked, a session with no usable token still routes to sign-in.
    • Restore checks availability before and after its token reads. Validation now also checks it before clearing, when its refresh-token re-read comes back empty.

Other suspects ruled out:

  • The Keychain access group (7WLXT3NR37.com.cmuxterm.app) and service (bundle-derived) are the same across releases.
  • Stack doesn't rotate refresh tokens (alwaysIssueNewRefreshToken: false), so there's no rotation race.
  • No path clears tokens on update.

No user report ties a specific status to the symptom. These are the code paths that sign out a live session, not observed causes.

This branch carries the review follow-up first pushed as https://github.com/manaflow-ai/cmux/pull/25759 (this branch rejects force pushes, so main is merged in rather than rebased). This PR supersedes it.

Testing

Focused command: the Auth and session lifecycle workflow (.github/workflows/auth-refresh-tests.yml). It runs the SDK refresh suites, including RefreshRejectionTests, and the CmuxAuthRuntime coordinator suites matching AuthCoordinator, which include AuthCoordinatorLockedStorageTests. KeychainStackTokenStoreTests runs in macos / swift-package-tests; at the head it passed with every suite in https://github.com/manaflow-ai/cmux/actions/runs/40834658783.

Red on the base, then green on the head:

Observed / Not run

  • Observed: the runs above.
  • Observed: tagged build 25628-signout-after-update of exact head d28a4101b6efa4be228d0bc7853c5b2936499c01 (fleet job 83f0908b40a5a00e2e05ee82, Linux pool), published to the Tag Opener.
  • Observed: its direct backend https://cmux-dev-backend-1.tail137216.ts.net:3886/ is running and serves the sign-in page.
  • Not run: a launch and sign-in across a real in-place Sparkle update. A tagged build is a separate bundle, so it can't reproduce an update of the installed app. Coverage is the behavior tests above.

Changelog

Fixed: cmux no longer signs you out after an update when the sign-in service rejects the app's request rather than your session, or when the Keychain is briefly unreadable

Proof

No UI change; the behavior tests above are the proof.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved sign-in reliability when secure token storage is temporarily unavailable. Existing sessions are preserved rather than incorrectly cleared during interrupted credential checks.
    • Improved handling of token refresh rejections: temporary or unrecognized server responses no longer immediately erase saved credentials, and repeated attempts are spaced out.
    • Definitive refresh-token failures, such as expired or invalid credentials, still end the session.