on main help sign in
fix(config): check config file for newer token before OAuth refresh

When refreshing an OAuth token (e.g. for Hyper), check the config file on disk first to see if another Crush session already refreshed it. If the disk token differs from the in-memory one, use the disk token and skip the external refresh request. Prevents unnecessary token churn and 401s when multiple Crush sessions are running. ๐Ÿ’˜ Generated with Crush Assisted-by: Kimi K2.6 via Crush <crush@charm.land>

commit99bc5ce850c1da02207ceba39f92afee6cba73fa authorAndrey Nering <andreynering@users.noreply.github.com> dateMon 4 May 2026, 17:10 (5 months ago) parent1ed6f52
2 files changed+244โˆ’7
internal/config/store.go+62โˆ’7 internal/config/store_test.go+182
internal/config/store.go+62โˆ’7
@@ -3,6 +3,7 @@ package config
33import (
44 "cmp"
55 "context"
6+ "encoding/json"
67 "fmt"
78 "log/slog"
89 "os"
@@ -288,6 +289,9 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
288289}
289290
290291// RefreshOAuthToken refreshes the OAuth token for the given provider.
292+// Before making an external refresh request, it checks the config file on
293+// disk to see if another Crush session has already refreshed the token. If
294+// a newer token is found, it is used instead of refreshing.
291295func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
292296 providerConfig, exists := s.config.Providers.Get(providerID)
293297 if !exists {
@@ -298,13 +302,29 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
298302 return fmt.Errorf("provider %s does not have an OAuth token", providerID)
299303 }
300304
301โˆ’ var newToken *oauth.Token
305+ // Check if another session refreshed the token recently by reading
306+ // the current token from the config file on disk.
307+ newToken, err := s.loadTokenFromDisk(scope, providerID)
308+ if err != nil {
309+ slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
310+ } else if newToken != nil && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
311+ slog.Info("Using token refreshed by another session", "provider", providerID)
312+ providerConfig.OAuthToken = newToken
313+ providerConfig.APIKey = newToken.AccessToken
314+ if providerID == string(catwalk.InferenceProviderCopilot) {
315+ providerConfig.SetupGitHubCopilot()
316+ }
317+ s.config.Providers.Set(providerID, providerConfig)
318+ return nil
319+ }
320+
321+ var refreshedToken *oauth.Token
302322 var refreshErr error
303323 switch providerID {
304324 case string(catwalk.InferenceProviderCopilot):
305โˆ’ newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
325+ refreshedToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken)
306326 case hyperp.Name:
307โˆ’ newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
327+ refreshedToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken)
308328 default:
309329 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
310330 }
@@ -313,8 +333,8 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
313333 }
314334
315335 slog.Info("Successfully refreshed OAuth token", "provider", providerID)
316โˆ’ providerConfig.OAuthToken = newToken
317โˆ’ providerConfig.APIKey = newToken.AccessToken
336+ providerConfig.OAuthToken = refreshedToken
337+ providerConfig.APIKey = refreshedToken.AccessToken
318338
319339 switch providerID {
320340 case string(catwalk.InferenceProviderCopilot):
@@ -324,8 +344,8 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
324344 s.config.Providers.Set(providerID, providerConfig)
325345
326346 if err := cmp.Or(
327โˆ’ s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), newToken.AccessToken),
328โˆ’ s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), newToken),
347+ s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), refreshedToken.AccessToken),
348+ s.SetConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID), refreshedToken),
329349 ); err != nil {
330350 return fmt.Errorf("failed to persist refreshed token: %w", err)
331351 }
@@ -333,6 +353,41 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
333353 return nil
334354}
335355
356+// loadTokenFromDisk reads the OAuth token for the given provider from the
357+// config file on disk. Returns nil if the token is not found or matches the
358+// current in-memory token.
359+func (s *ConfigStore) loadTokenFromDisk(scope Scope, providerID string) (*oauth.Token, error) {
360+ path, err := s.configPath(scope)
361+ if err != nil {
362+ return nil, err
363+ }
364+
365+ data, err := os.ReadFile(path)
366+ if err != nil {
367+ if os.IsNotExist(err) {
368+ return nil, nil
369+ }
370+ return nil, err
371+ }
372+
373+ oauthKey := fmt.Sprintf("providers.%s.oauth", providerID)
374+ oauthResult := gjson.Get(string(data), oauthKey)
375+ if !oauthResult.Exists() {
376+ return nil, nil
377+ }
378+
379+ var token oauth.Token
380+ if err := json.Unmarshal([]byte(oauthResult.Raw), &token); err != nil {
381+ return nil, err
382+ }
383+
384+ if token.AccessToken == "" {
385+ return nil, nil
386+ }
387+
388+ return &token, nil
389+}
390+
336391// recordRecentModel records a model in the recent models list.
337392func (s *ConfigStore) recordRecentModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
338393 if model.Provider == "" || model.Model == "" {
internal/config/store_test.go+182
@@ -8,6 +8,8 @@ import (
88 "testing"
99 "time"
1010
11+ "github.com/charmbracelet/crush/internal/csync"
12+ "github.com/charmbracelet/crush/internal/oauth"
1113 "github.com/stretchr/testify/require"
1214)
1315
@@ -510,3 +512,183 @@ func TestAutoReloadDisabledDuringReload(t *testing.T) {
510512 // Verify reload completed successfully
511513 require.False(t, store.autoReloadDisabled, "autoReloadDisabled should be false after ReloadFromDisk")
512514}
515+
516+func TestLoadTokenFromDisk_ReturnsNewerToken(t *testing.T) {
517+ t.Parallel()
518+
519+ dir := t.TempDir()
520+ configPath := filepath.Join(dir, "crush.json")
521+
522+ // Create config file with a newer token on disk
523+ configContent := `{
524+ "providers": {
525+ "hyper": {
526+ "oauth": {
527+ "access_token": "newer-token-from-disk",
528+ "refresh_token": "refresh-abc",
529+ "expires_in": 3600,
530+ "expires_at": 9999999999
531+ }
532+ }
533+ }
534+ }`
535+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
536+
537+ store := &ConfigStore{
538+ config: &Config{},
539+ globalDataPath: configPath,
540+ }
541+
542+ token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
543+ require.NoError(t, err)
544+ require.NotNil(t, token)
545+ require.Equal(t, "newer-token-from-disk", token.AccessToken)
546+ require.Equal(t, "refresh-abc", token.RefreshToken)
547+ require.Equal(t, 3600, token.ExpiresIn)
548+ require.Equal(t, int64(9999999999), token.ExpiresAt)
549+}
550+
551+func TestLoadTokenFromDisk_ReturnsNilWhenSameToken(t *testing.T) {
552+ t.Parallel()
553+
554+ dir := t.TempDir()
555+ configPath := filepath.Join(dir, "crush.json")
556+
557+ // Create config file with the same token
558+ configContent := `{
559+ "providers": {
560+ "hyper": {
561+ "oauth": {
562+ "access_token": "same-token",
563+ "refresh_token": "refresh-abc",
564+ "expires_in": 3600,
565+ "expires_at": 9999999999
566+ }
567+ }
568+ }
569+ }`
570+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
571+
572+ store := &ConfigStore{
573+ config: &Config{},
574+ globalDataPath: configPath,
575+ }
576+
577+ token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
578+ require.NoError(t, err)
579+ require.NotNil(t, token)
580+ require.Equal(t, "same-token", token.AccessToken)
581+}
582+
583+func TestLoadTokenFromDisk_ReturnsNilWhenFileMissing(t *testing.T) {
584+ t.Parallel()
585+
586+ dir := t.TempDir()
587+ configPath := filepath.Join(dir, "nonexistent.json")
588+
589+ store := &ConfigStore{
590+ config: &Config{},
591+ globalDataPath: configPath,
592+ }
593+
594+ token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
595+ require.NoError(t, err)
596+ require.Nil(t, token)
597+}
598+
599+func TestLoadTokenFromDisk_ReturnsNilWhenProviderMissing(t *testing.T) {
600+ t.Parallel()
601+
602+ dir := t.TempDir()
603+ configPath := filepath.Join(dir, "crush.json")
604+
605+ // Create config file without the hyper provider
606+ configContent := `{"providers": {"openai": {"api_key": "test-key"}}}`
607+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
608+
609+ store := &ConfigStore{
610+ config: &Config{},
611+ globalDataPath: configPath,
612+ }
613+
614+ token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
615+ require.NoError(t, err)
616+ require.Nil(t, token)
617+}
618+
619+func TestLoadTokenFromDisk_ReturnsNilWhenOAuthMissing(t *testing.T) {
620+ t.Parallel()
621+
622+ dir := t.TempDir()
623+ configPath := filepath.Join(dir, "crush.json")
624+
625+ // Create config file with provider but no OAuth token
626+ configContent := `{"providers": {"hyper": {"api_key": "test-key"}}}`
627+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
628+
629+ store := &ConfigStore{
630+ config: &Config{},
631+ globalDataPath: configPath,
632+ }
633+
634+ token, err := store.loadTokenFromDisk(ScopeGlobal, "hyper")
635+ require.NoError(t, err)
636+ require.Nil(t, token)
637+}
638+
639+func TestRefreshOAuthToken_UsesDiskTokenWhenDifferent(t *testing.T) {
640+ t.Parallel()
641+
642+ dir := t.TempDir()
643+ configPath := filepath.Join(dir, "crush.json")
644+
645+ // Create config file with a newer token on disk
646+ configContent := `{
647+ "providers": {
648+ "hyper": {
649+ "api_key": "newer-access-token",
650+ "oauth": {
651+ "access_token": "newer-access-token",
652+ "refresh_token": "refresh-abc",
653+ "expires_in": 3600,
654+ "expires_at": 9999999999
655+ }
656+ }
657+ }
658+ }`
659+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0o600))
660+
661+ // Set up store with an older in-memory token
662+ oldToken := &oauth.Token{
663+ AccessToken: "older-access-token",
664+ RefreshToken: "refresh-abc",
665+ ExpiresIn: 3600,
666+ ExpiresAt: time.Now().Add(-time.Hour).Unix(), // Expired
667+ }
668+
669+ providers := csync.NewMap[string, ProviderConfig]()
670+ providers.Set("hyper", ProviderConfig{
671+ ID: "hyper",
672+ Name: "Hyper",
673+ APIKey: oldToken.AccessToken,
674+ OAuthToken: oldToken,
675+ })
676+
677+ store := &ConfigStore{
678+ config: &Config{
679+ Providers: providers,
680+ },
681+ globalDataPath: configPath,
682+ }
683+
684+ // Refresh should use the disk token without making an external call
685+ err := store.RefreshOAuthToken(context.Background(), ScopeGlobal, "hyper")
686+ require.NoError(t, err)
687+
688+ // Verify the in-memory token was updated to the disk token
689+ updatedConfig, ok := store.config.Providers.Get("hyper")
690+ require.True(t, ok)
691+ require.Equal(t, "newer-access-token", updatedConfig.APIKey)
692+ require.Equal(t, "newer-access-token", updatedConfig.OAuthToken.AccessToken)
693+ require.Equal(t, "refresh-abc", updatedConfig.OAuthToken.RefreshToken)
694+}