@@ -158,7 +158,7 @@ func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
158 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
158 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
159 return fmt.Errorf("failed to create config directory %q: %w", path, err)
159 return fmt.Errorf("failed to create config directory %q: %w", path, err)
161− if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
161+ if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
162 return fmt.Errorf("failed to write config file: %w", err)
162 return fmt.Errorf("failed to write config file: %w", err)
@@ -193,7 +193,7 @@ func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
193 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
193 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
194 return fmt.Errorf("failed to create config directory %q: %w", path, err)
194 return fmt.Errorf("failed to create config directory %q: %w", path, err)
196− if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
196+ if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
197 return fmt.Errorf("failed to write config file: %w", err)
197 return fmt.Errorf("failed to write config file: %w", err)
@@ -302,7 +302,10 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
302// RefreshOAuthToken refreshes the OAuth token for the given provider.
302// RefreshOAuthToken refreshes the OAuth token for the given provider.
303// Before making an external refresh request, it checks the config file on
303// Before making an external refresh request, it checks the config file on
304// disk to see if another Crush session has already refreshed the token. If
304// disk to see if another Crush session has already refreshed the token. If
305−// a newer token is found, it is used instead of refreshing.
305+// a newer, unexpired token is found, it is used instead of refreshing. If
306+// the exchange fails (e.g. because another session already rotated the
307+// refresh token), the disk is re-checked to recover the other session's
306func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
309func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
307 providerConfig, exists := s.config.Providers.Get(providerID)
310 providerConfig, exists := s.config.Providers.Get(providerID)
308 if !exists {
311 if !exists {
@@ -318,15 +321,9 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
318 newToken, err := s.loadTokenFromDisk(scope, providerID)
321 newToken, err := s.loadTokenFromDisk(scope, providerID)
319 if err != nil {
322 if err != nil {
320 slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
323 slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
321− } else if newToken != nil && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
324+ } else if newToken != nil && !newToken.IsExpired() && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
322 slog.Info("Using token refreshed by another session", "provider", providerID)
325 slog.Info("Using token refreshed by another session", "provider", providerID)
323− providerConfig.OAuthToken = newToken
326+ return s.applyToken(providerConfig, newToken, providerID)
324− providerConfig.APIKey = newToken.AccessToken
325− if providerID == string(catwalk.InferenceProviderCopilot) {
326− providerConfig.SetupGitHubCopilot()
328− s.config.Providers.Set(providerID, providerConfig)
332 var refreshedToken *oauth.Token
329 var refreshedToken *oauth.Token
@@ -340,6 +337,16 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
340 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
337 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
342 if refreshErr != nil {
339 if refreshErr != nil {
340+ // The exchange may have failed because another session already
341+ // rotated the refresh token. Re-read the config file and use the
342+ // other session's token if available.
343+ if diskToken, diskErr := s.loadTokenFromDisk(scope, providerID); diskErr == nil &&
345+ !diskToken.IsExpired() &&
346+ diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
347+ slog.Info("Using token refreshed by another session after exchange failure", "provider", providerID)
348+ return s.applyToken(providerConfig, diskToken, providerID)
343 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
350 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
@@ -364,6 +371,17 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
364 return nil
371 return nil
374+// applyToken updates the in-memory provider config with the given token.
375+func (s *ConfigStore) applyToken(providerConfig ProviderConfig, token *oauth.Token, providerID string) error {
376+ providerConfig.OAuthToken = token
377+ providerConfig.APIKey = token.AccessToken
378+ if providerID == string(catwalk.InferenceProviderCopilot) {
379+ providerConfig.SetupGitHubCopilot()
381+ s.config.Providers.Set(providerID, providerConfig)
367// loadTokenFromDisk reads the OAuth token for the given provider from the
385// loadTokenFromDisk reads the OAuth token for the given provider from the
368// config file on disk. Returns nil if the token is not found or matches the
386// config file on disk. Returns nil if the token is not found or matches the
369// current in-memory token.
387// current in-memory token.