Skip to content

Commit 64f186f

Browse files
ampagentPierrunoYT
andcommitted
fix(auth): surface OAuth setup failures
Amp-Thread-ID: https://ampcode.com/threads/T-019fb8cf-a980-7568-80f1-fe34faaaecae Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
1 parent 780a219 commit 64f186f

5 files changed

Lines changed: 118 additions & 8 deletions

File tree

internal/cli/provider_onboarding.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,13 +509,16 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps
509509
// non-default config path cannot leave the encrypted key behind.
510510
keyRemoved := false
511511
var keyErr error
512+
markerTransferFailed := false
512513
if survivor, ok := caseVariantProviderName(cfg, name); ok {
513514
if hadBefore && before.APIKeyStored {
514515
if survivorRow, foundSurvivor, err := config.ProviderRow(configPath, survivor); err != nil {
515516
keyErr = err
517+
markerTransferFailed = true
516518
} else if foundSurvivor && !survivorRow.APIKeyStored {
517519
if _, err := config.TransferProviderAPIKeyStoredMarker(configPath, survivor); err != nil {
518520
keyErr = err
521+
markerTransferFailed = true
519522
}
520523
}
521524
}
@@ -542,7 +545,11 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps
542545
return exitCrash
543546
}
544547
if keyErr != nil {
545-
if _, err := fmt.Fprintf(stderr, "warning: its stored API key could not be deleted and remains in the credential store: %v\n", keyErr); err != nil {
548+
warning := "its stored API key could not be deleted and remains in the credential store"
549+
if markerTransferFailed {
550+
warning = "the stored API key marker could not be transferred to the surviving case-variant provider, so the shared key may be unreachable"
551+
}
552+
if _, err := fmt.Fprintf(stderr, "warning: %s: %v\n", warning, keyErr); err != nil {
546553
return exitCrash
547554
}
548555
} else if keyRemoved {

internal/tui/onboarding.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -575,8 +575,11 @@ type setupOAuthDeviceMsg struct {
575575
err error
576576
}
577577

578-
func setupDevicePrepareCmd(name string) tea.Cmd {
578+
func setupDevicePrepareCmd(name string, configPath ...string) tea.Cmd {
579579
return func() tea.Msg {
580+
if err := preflightOAuthUserConfig(firstString(configPath)); err != nil {
581+
return setupOAuthDeviceMsg{providerID: name, err: err}
582+
}
580583
auth, cfg, err := oauthDevicePrepare(name)
581584
if err != nil {
582585
return setupOAuthDeviceMsg{providerID: name, err: err}
@@ -612,7 +615,7 @@ func (m model) startSetupDeviceLogin(descriptor providercatalog.Descriptor) (tea
612615
m.setup.oauthErr = ""
613616
m.setup.deviceUserCode = ""
614617
m.setup.deviceVerificationURI = ""
615-
return m, setupDevicePrepareCmd(descriptor.ID)
618+
return m, setupDevicePrepareCmd(descriptor.ID, m.setup.configPath)
616619
}
617620

618621
// applySetupOAuthDeviceCode handles phase 1 of device-code login: show the code,
@@ -660,9 +663,12 @@ func (m model) applySetupOAuth(msg setupOAuthMsg) (tea.Model, tea.Cmd) {
660663
if msg.tokenLogin {
661664
// Early persist on the Update goroutine (see persistOAuthLoginProvider's
662665
// threading contract) so quitting setup after the login doesn't lose it;
663-
// completeSetup persists the full profile with the chosen model anyway,
664-
// so a failure here is recoverable and not fatal to setup.
665-
_ = persistOAuthLoginProvider(m.setup.configPath, msg.providerID)
666+
// do not advance when that write fails or the stored token would have no
667+
// reachable provider profile after setup exits.
668+
if err := persistOAuthLoginProvider(m.setup.configPath, msg.providerID); err != nil {
669+
m.setup.oauthErr = "Signed in, but the provider profile could not be saved: " + redaction.ErrorMessage(err, redaction.Options{})
670+
return m, nil
671+
}
666672
}
667673
m.setup.oauthErr = ""
668674
m.setup.err = ""
@@ -882,6 +888,9 @@ func (m model) completeSetup() (tea.Model, tea.Cmd) {
882888
})
883889
if err != nil {
884890
m.setup.err = err.Error()
891+
if m.setup.oauthMode && m.setupProviderDescriptor().OAuthMintsKey && strings.TrimSpace(apiKey) != "" {
892+
m.setup.err = oauthMintedKeySaveError(m.setup.err, apiKey)
893+
}
885894
return m, nil
886895
}
887896

@@ -911,6 +920,10 @@ func (m model) completeSetup() (tea.Model, tea.Cmd) {
911920
return m.exitSetupToChat()
912921
}
913922

923+
func oauthMintedKeySaveError(message, apiKey string) string {
924+
return fmt.Sprintf("%s\nThe minted API key was not saved. Copy it now before leaving Zero:\n%s", message, apiKey)
925+
}
926+
914927
func (m *model) resetSetupModels() {
915928
option := m.setupProvider()
916929
provider := setupProviderDescriptor(option)

internal/tui/onboarding_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,61 @@ func TestApplySetupOAuthSuccessAdvancesToModel(t *testing.T) {
20662066
}
20672067
}
20682068

2069+
func TestApplySetupOAuthTokenPersistFailureStaysOnProvider(t *testing.T) {
2070+
m := newModel(context.Background(), Options{
2071+
Setup: SetupOptions{Visible: true, Providers: []SetupProviderOption{
2072+
{ID: "xai", Name: "xAI", RequiresAuth: true},
2073+
}},
2074+
})
2075+
m.setup.stage = setupStageProvider
2076+
m.setup.oauthPending = true
2077+
m.setup.oauthMode = true
2078+
m.setup.configPath = t.TempDir() // A directory cannot be read as config.json.
2079+
2080+
updated, cmd := m.applySetupOAuth(setupOAuthMsg{tokenLogin: true, providerID: "xai"})
2081+
next := updated.(model)
2082+
if cmd != nil {
2083+
t.Fatal("failed profile persistence should not start model discovery")
2084+
}
2085+
if next.setup.stage != setupStageProvider {
2086+
t.Fatalf("stage = %v, want provider", next.setup.stage)
2087+
}
2088+
if next.setup.oauthErr == "" || !strings.Contains(next.setup.oauthErr, "could not be saved") {
2089+
t.Fatalf("oauthErr = %q, want profile-save failure", next.setup.oauthErr)
2090+
}
2091+
}
2092+
2093+
func TestCompleteSetupSurfacesMintedOpenRouterKeyWhenSaveFails(t *testing.T) {
2094+
const mintedKey = "sk-or-minted-recovery"
2095+
m := newModel(context.Background(), Options{
2096+
Setup: SetupOptions{
2097+
Visible: true,
2098+
Providers: []SetupProviderOption{
2099+
{ID: "openrouter", Name: "OpenRouter", RequiresAuth: true},
2100+
},
2101+
Save: func(SetupSelection) (SetupResult, error) {
2102+
return SetupResult{}, errors.New("config write failed")
2103+
},
2104+
},
2105+
})
2106+
m.setup.oauthMode = true
2107+
m.setup.stage = setupStageReady
2108+
m.setup.apiKey.SetValue(mintedKey)
2109+
2110+
updated, _ := m.completeSetup()
2111+
next := updated.(model)
2112+
if !strings.Contains(next.setup.err, mintedKey) || !strings.Contains(next.setup.err, "was not saved") {
2113+
t.Fatalf("setup error = %q, want minted-key recovery", next.setup.err)
2114+
}
2115+
}
2116+
2117+
func TestSetupDevicePreparePreflightsConfigBeforeRequestingCode(t *testing.T) {
2118+
msg := setupDevicePrepareCmd("xai", t.TempDir())().(setupOAuthDeviceMsg)
2119+
if msg.err == nil {
2120+
t.Fatal("device-code preparation should reject an unreadable config before requesting a code")
2121+
}
2122+
}
2123+
20692124
func pressSetupContinue(m model) model {
20702125
m = pressSetupContinueOnce(m)
20712126
// Transparently skip the connect-method chooser via the API-key/browse path so

internal/tui/provider_wizard.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,11 @@ type providerWizardDeviceCodeMsg struct {
327327

328328
// providerWizardDevicePrepareCmd runs phase 1 of the device-code login off the UI
329329
// goroutine and reports the code to display (or an error).
330-
func providerWizardDevicePrepareCmd(name string, attemptID int) tea.Cmd {
330+
func providerWizardDevicePrepareCmd(name string, attemptID int, configPath ...string) tea.Cmd {
331331
return func() tea.Msg {
332+
if err := preflightOAuthUserConfig(firstString(configPath)); err != nil {
333+
return providerWizardDeviceCodeMsg{providerID: name, attemptID: attemptID, err: err}
334+
}
332335
auth, cfg, err := oauthDevicePrepare(name)
333336
if err != nil {
334337
return providerWizardDeviceCodeMsg{providerID: name, attemptID: attemptID, err: err}
@@ -364,7 +367,7 @@ func (m model) startProviderDeviceLogin() (model, tea.Cmd) {
364367
return m, nil
365368
}
366369
attemptID := m.providerWizard.beginOAuthAttempt(true)
367-
return m, providerWizardDevicePrepareCmd(provider.ID, attemptID)
370+
return m, providerWizardDevicePrepareCmd(provider.ID, attemptID, m.userConfigPath)
368371
}
369372

370373
const maxProviderWizardProvidersVisible = 10
@@ -1300,11 +1303,17 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
13001303
adopted, adoptErr := config.AdoptPersistedCatalogProviderName(m.userConfigPath, profile)
13011304
if adoptErr != nil {
13021305
wizard.err = adoptErr.Error()
1306+
if provider.OAuthMintsKey && strings.TrimSpace(wizard.apiKey) != "" {
1307+
wizard.err = oauthMintedKeySaveError(wizard.err, wizard.apiKey)
1308+
}
13031309
return m, nil
13041310
}
13051311
profile = adopted
13061312
if err := config.PreflightProviderWrite(m.userConfigPath, profile.Name); err != nil {
13071313
wizard.err = err.Error()
1314+
if provider.OAuthMintsKey && strings.TrimSpace(wizard.apiKey) != "" {
1315+
wizard.err = oauthMintedKeySaveError(wizard.err, wizard.apiKey)
1316+
}
13081317
return m, nil
13091318
}
13101319
// Capture flip: move the freshly entered key into the encrypted credential
@@ -1316,6 +1325,9 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
13161325
}
13171326
if _, err := config.UpsertProvider(m.userConfigPath, profile, true); err != nil {
13181327
wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{secret, profile.APIKey}})
1328+
if provider.OAuthMintsKey && strings.TrimSpace(secret) != "" {
1329+
wizard.err = oauthMintedKeySaveError(wizard.err, secret)
1330+
}
13191331
return m, nil // nothing committed to live state yet
13201332
}
13211333
}

internal/tui/provider_wizard_oauth_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,29 @@ func TestApplyProviderWizardOAuthIgnoresStaleAttempt(t *testing.T) {
364364
}
365365
}
366366

367+
func TestProviderWizardSurfacesMintedOpenRouterKeyWhenSaveFails(t *testing.T) {
368+
const mintedKey = "sk-or-wizard-recovery"
369+
m := wizardModelAt(t, "openrouter", providerWizardStepDone)
370+
m.providerWizard.oauthMode = true
371+
m.providerWizard.apiKey = mintedKey
372+
m.userConfigPath = t.TempDir() // A directory cannot be read as config.json.
373+
374+
next, _ := m.applyProviderWizard()
375+
if !strings.Contains(next.providerWizard.err, mintedKey) || !strings.Contains(next.providerWizard.err, "was not saved") {
376+
t.Fatalf("wizard error = %q, want minted-key recovery", next.providerWizard.err)
377+
}
378+
}
379+
380+
func TestProviderWizardDevicePreparePreflightsConfigBeforeRequestingCode(t *testing.T) {
381+
msg := providerWizardDevicePrepareCmd("xai", 42, t.TempDir())().(providerWizardDeviceCodeMsg)
382+
if msg.err == nil {
383+
t.Fatal("device-code preparation should reject an unreadable config before requesting a code")
384+
}
385+
if msg.attemptID != 42 {
386+
t.Fatalf("attemptID = %d, want 42", msg.attemptID)
387+
}
388+
}
389+
367390
func TestProviderWizardDeviceCodeIgnoresStaleAttempt(t *testing.T) {
368391
m := mouseTestModel()
369392
m.providerWizard = m.newProviderWizard()

0 commit comments

Comments
 (0)