From 733cc18cc50e7e727399b18f86acc5516197d0cc Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Wed, 12 Aug 2026 12:08:40 +0200 Subject: [PATCH 01/17] fix(config): define provider identity primitives and duplicate-name validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persisted provider rows and credential-store entries answer two different questions, and mixing them let one profile's mutation reach another's row and secret. This introduces the single identity rule and splits the two: - credstore.NormalizeProvider is now exported as the store's own provider-name equivalence rule (trim + ToLower). Callers deciding whether two spellings share one stored secret must use it rather than strings.EqualFold: Unicode case folding equates "s" and "ſ" while strings.ToLower does not, so an EqualFold comparison can promise a survivor access to a key it can never look up. - config.ValidatePersistedProviderNames rejects persisted rows that repeat a folded identity, whether the spellings are identical or only case variants; writeConfigFile guards every write with it, and Resolve() validates user config before merging. - config.SameProviderIdentity / sameProviderIdentity expose that rule to config mutators and future UI/CLI callers. Operations that address a persisted ROW now match its exact spelling: MarkProviderAPIKeyStored, SetActiveProvider, ProviderPersisted, SetProviderModel, ClearProviderKeyStored, RemoveProvider's index lookup, and the oldName lookups in RenameProvider/EditProvider. Operations that reason about a shared CREDENTIAL use identity: new-name collision checks, active-provider handoff, migrateStoredProviderKey's case-only-rename early return, and the new ClearProviderKeyStoredCaseVariants. normalizeProvidersWithOptions selects the active row before normalizing anything: an exact name always wins, credential identity is a fallback only when it identifies exactly one row, and an ambiguous fallback is an error instead of an arbitrary pick. PreflightUserConfig, PreflightProviderWrite, PersistedProviderNames and ClearProviderKeyStoredCaseVariants have no callers yet; the follow-up PRs in this split wire them into the CLI and TUI. This is PR1 of a 4-PR split of #725, addressing review feedback that the combined branch was too large to review. Refs #721. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Pierre Bruno --- internal/config/credentials.go | 39 ++++- internal/config/credentials_test.go | 30 ++++ internal/config/resolver.go | 36 +++- internal/config/resolver_test.go | 102 ++++++++++++ internal/config/writer.go | 209 +++++++++++++++++++++-- internal/config/writer_test.go | 250 +++++++++++++++++++++++++++- internal/credstore/credstore.go | 11 ++ 7 files changed, 648 insertions(+), 29 deletions(-) diff --git a/internal/config/credentials.go b/internal/config/credentials.go index f9432cfd2..d4e5d6816 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -99,7 +99,44 @@ func ClearProviderKeyStored(path, provider string) (bool, error) { } changed := false for index := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), provider) && cfg.Providers[index].APIKeyStored { + if strings.TrimSpace(cfg.Providers[index].Name) == provider && cfg.Providers[index].APIKeyStored { + cfg.Providers[index].APIKeyStored = false + changed = true + } + } + if !changed { + return false, nil + } + return true, writeConfigFile(path, cfg) +} + +// ClearProviderKeyStoredCaseVariants unsets the APIKeyStored marker on every +// row whose name normalizes to the same credential-store identity as provider, +// not just an exact-spelling match. Deleting the shared secret for one +// case-variant row (e.g. "WORK") must also clear the marker on any sibling row +// ("work") that pointed at the same now-gone entry — leaving it set would claim +// a key is available when ApplyStoredAPIKey's store lookup will always miss. +func ClearProviderKeyStoredCaseVariants(path, provider string) (bool, error) { + path = strings.TrimSpace(path) + provider = strings.TrimSpace(provider) + if path == "" || provider == "" { + return false, nil + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("read config %s: %w", path, err) + } + var cfg FileConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return false, fmt.Errorf("invalid config JSON %s: %w", path, err) + } + changed := false + providerIdentity := credstore.NormalizeProvider(provider) + for index := range cfg.Providers { + if credstore.NormalizeProvider(cfg.Providers[index].Name) == providerIdentity && cfg.Providers[index].APIKeyStored { cfg.Providers[index].APIKeyStored = false changed = true } diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go index d627a5cb4..7dc9aacb7 100644 --- a/internal/config/credentials_test.go +++ b/internal/config/credentials_test.go @@ -157,6 +157,16 @@ func TestClearProviderKeyStored(t *testing.T) { if cleared, _ := ClearProviderKeyStored(path, "nope"); cleared { t.Fatal("unknown provider should report no change") } + if err := os.WriteFile(path, []byte(`{"providers":[{"name":"work","apiKeyStored":true}]}`), 0o600); err != nil { + t.Fatal(err) + } + if cleared, err := ClearProviderKeyStored(path, "WORK"); err != nil || cleared { + t.Fatalf("case-variant clear = %v,%v; want false,nil", cleared, err) + } + cfg = readConfigFixture(t, path) + if !cfg.Providers[0].APIKeyStored { + t.Fatalf("clear must require exact provider identity: %+v", cfg.Providers) + } } func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) { @@ -317,3 +327,23 @@ func TestProviderProfileMissingCredentialEnv(t *testing.T) { }) } } + +func TestClearProviderKeyStoredCaseVariantsPreservesDistinctUnicodeIdentity(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"providers":[{"name":"s","apiKeyStored":true},{"name":"ſ","apiKeyStored":true}]}`), 0o600); err != nil { + t.Fatal(err) + } + + cleared, err := ClearProviderKeyStoredCaseVariants(path, "s") + if err != nil || !cleared { + t.Fatalf("clear = %v,%v; want true,nil", cleared, err) + } + cfg := readConfigFixture(t, path) + if cfg.Providers[0].APIKeyStored { + t.Fatal("s marker should be cleared") + } + if !cfg.Providers[1].APIKeyStored { + t.Fatal("long-s marker belongs to a distinct credential-store identity and must remain set") + } +} diff --git a/internal/config/resolver.go b/internal/config/resolver.go index 16936874d..55436d44e 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -74,6 +74,9 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) { if err != nil { return ResolvedConfig{}, err } + if err := ValidatePersistedProviderNames(fileConfig); err != nil { + return ResolvedConfig{}, err + } mergeConfig(&cfg, fileConfig) } if options.ProjectConfigPath != "" { @@ -957,26 +960,51 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin } if activeName == "" && len(providers) == 1 { - activeName = providers[0].Name + activeName = strings.TrimSpace(providers[0].Name) + } + + // Select the active source row before normalizing anything. An exact name + // always wins; credential-store identity is only a fallback when it identifies + // one row. This prevents an invalid case-variant sibling from making an exact + // target fail while keeping distinct identities such as "s" and "ſ" separate. + activeIndex := -1 + if activeName != "" { + for index := range providers { + if strings.TrimSpace(providers[index].Name) == activeName { + activeIndex = index + break + } + } + if activeIndex < 0 { + for index := range providers { + if !sameProviderIdentity(providers[index].Name, activeName) { + continue + } + if activeIndex >= 0 { + return nil, ProviderProfile{}, fmt.Errorf("ambiguous active provider %q: multiple provider names differ only by case", activeName) + } + activeIndex = index + } + } } normalized := make([]ProviderProfile, 0, len(providers)) var active ProviderProfile activeFound := false - for _, provider := range providers { + for index, provider := range providers { next, err := normalizeProvider(provider, env, options) if err != nil { // One unresolvable provider (e.g. a profile referencing a provider preset // this build doesn't ship) must NOT brick the whole app — drop it and keep // the rest. Only the ACTIVE provider failing is fatal, since the run can't // proceed without it. - if strings.TrimSpace(provider.Name) == activeName { + if index == activeIndex { return nil, ProviderProfile{}, err } continue } normalized = append(normalized, next) - if next.Name == activeName { + if index == activeIndex { active = next activeFound = true } diff --git a/internal/config/resolver_test.go b/internal/config/resolver_test.go index 13038664e..0bbfb78cd 100644 --- a/internal/config/resolver_test.go +++ b/internal/config/resolver_test.go @@ -2,6 +2,7 @@ package config import ( "errors" + "fmt" "os" "path/filepath" "reflect" @@ -2252,3 +2253,104 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) { t.Fatalf("error = %v", err) } } + +func TestNormalizeProvidersMatchesResolvedActiveNameCaseInsensitively(t *testing.T) { + providers, active, err := normalizeProviders([]ProviderProfile{{ + Name: "EnvProvider", + ProviderKind: ProviderKindOpenAI, + Model: "gpt-4.1", + }}, "envprovider") + if err != nil { + t.Fatalf("normalizeProviders() error = %v", err) + } + if len(providers) != 1 || active.Name != "EnvProvider" { + t.Fatalf("resolved active = %+v from %+v, want EnvProvider", active, providers) + } +} + +func TestNormalizeProvidersSelectsActiveSourceBeforeNormalization(t *testing.T) { + valid := func(name string) ProviderProfile { + return ProviderProfile{Name: name, ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"} + } + t.Run("exact wins over folded invalid sibling", func(t *testing.T) { + providers, active, err := normalizeProviders([]ProviderProfile{ + valid("Target"), + {Name: "target", ProviderKind: "invalid", Model: "broken"}, + }, " Target ") + if err != nil { + t.Fatalf("normalizeProviders() error = %v", err) + } + if active.Name != "Target" || len(providers) != 1 { + t.Fatalf("active = %+v, providers = %+v; want exact Target only", active, providers) + } + }) + + t.Run("unique folded fallback", func(t *testing.T) { + _, active, err := normalizeProviders([]ProviderProfile{valid("Target")}, "target") + if err != nil { + t.Fatalf("normalizeProviders() error = %v", err) + } + if active.Name != "Target" { + t.Fatalf("active.Name = %q, want Target", active.Name) + } + }) + + t.Run("multiple folded matches are ambiguous", func(t *testing.T) { + _, _, err := normalizeProviders([]ProviderProfile{valid("Target"), valid("TARGET")}, "target") + const want = `ambiguous active provider "target": multiple provider names differ only by case` + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + }) +} + +func TestNormalizeProvidersActiveFallbackUsesCredentialIdentity(t *testing.T) { + providers, active, err := normalizeProviders([]ProviderProfile{ + {Name: "s", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://s.example/v1", Model: "s-model"}, + {Name: "ſ", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://long-s.example/v1", Model: "long-s-model"}, + }, "S") + if err != nil { + t.Fatalf("normalizeProviders() error = %v", err) + } + if len(providers) != 2 || active.Name != "s" || active.Model != "s-model" { + t.Fatalf("providers=%#v active=%#v, want credential identity s selected", providers, active) + } +} + +func TestResolveCrossLayerActiveProviderCaseMatching(t *testing.T) { + valid := func(name string) string { + return fmt.Sprintf(`{"providers":[{"name":%q,"providerKind":"openai","model":"gpt-4.1"}]}`, name) + } + t.Run("exact user active wins over project case variant", func(t *testing.T) { + userPath := writeConfig(t, `{"activeProvider":"Target","providers":[{"name":"Target","providerKind":"openai","model":"gpt-4.1"}]}`) + projectPath := writeConfig(t, valid("target")) + resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if resolved.ActiveProvider != "Target" { + t.Fatalf("active provider = %q, want exact Target", resolved.ActiveProvider) + } + }) + + t.Run("folded cross-layer target is ambiguous", func(t *testing.T) { + userPath := writeConfig(t, `{"activeProvider":"target","providers":[{"name":"Target","providerKind":"openai","model":"gpt-4.1"}]}`) + projectPath := writeConfig(t, valid("TARGET")) + _, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}}) + const want = `ambiguous active provider "target": multiple provider names differ only by case` + if err == nil || err.Error() != want { + t.Fatalf("Resolve() error = %v, want %q", err, want) + } + }) +} + +func TestResolvePreservesSoleOpenRouterCaseVariant(t *testing.T) { + path := writeConfig(t, `{"activeProvider":"openrouter","providers":[{"name":"OpenRouter","catalogId":"openrouter","providerKind":"openai-compatible","baseURL":"https://openrouter.ai/api/v1","model":"openai/gpt-4.1"}]}`) + resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if resolved.ActiveProvider != "OpenRouter" { + t.Fatalf("active provider name = %q, want preserved OpenRouter", resolved.ActiveProvider) + } +} diff --git a/internal/config/writer.go b/internal/config/writer.go index e3b6846f2..c4e7c9091 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -8,9 +8,139 @@ import ( "sort" "strings" + "github.com/Gitlawb/zero/internal/credstore" "github.com/Gitlawb/zero/internal/providercatalog" ) +// ValidatePersistedProviderNames rejects user-config rows that share the same +// case-insensitive identity. Credential-store keys are case-insensitive, so +// allowing both rows would make writes and deletes affect a shared secret. +// This validator intentionally applies only to raw persisted user config, not +// to profiles merged from project, environment, or provider-command layers. +// +// A repeated folded identity is rejected whether or not the spellings differ. +// Exact duplicates are just as broken as case variants: resolver merging +// silently coalesces the rows, and plaintext-key migration writes both values +// into the same normalized credential-store entry, so the second row's key +// overwrites the first. +func ValidatePersistedProviderNames(cfg FileConfig) error { + seen := make(map[string]string, len(cfg.Providers)) + for _, provider := range cfg.Providers { + name := strings.TrimSpace(provider.Name) + folded := credstore.NormalizeProvider(name) + previous, ok := seen[folded] + if ok && previous == name { + return fmt.Errorf("duplicate persisted provider name %q; remove one of the rows in config.json", name) + } + if ok { + return fmt.Errorf("ambiguous persisted provider names %q and %q differ only by case; rename or remove one row in config.json", previous, name) + } + seen[folded] = name + } + return nil +} + +// sameProviderIdentity reports whether two persisted spellings name the same +// provider identity. It is credstore.NormalizeProvider — the credential store's +// own rule — rather than strings.EqualFold, because the two disagree and the +// store is the authority: EqualFold folds "s" and Unicode long-s "ſ" together, +// while the store keeps separate entries for them. Treating them as one identity +// let a mutation of one profile reach the other's row and its secret, which is +// precisely what ValidatePersistedProviderNames permits as a distinct pair. +func sameProviderIdentity(a string, b string) bool { + return credstore.NormalizeProvider(a) == credstore.NormalizeProvider(b) +} + +// SameProviderIdentity exposes the credential store's provider-name identity +// rule to UI and CLI list operations. It deliberately differs from +// strings.EqualFold for Unicode spellings such as "s" and long-s. +func SameProviderIdentity(a string, b string) bool { + return sameProviderIdentity(strings.TrimSpace(a), strings.TrimSpace(b)) +} + +// PreflightUserConfig validates existing user config before any command makes +// credential-store side effects. +func PreflightUserConfig(path string) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("config path is required") + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read config %s: %w", path, err) + } + var cfg FileConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("invalid config JSON %s: %w", path, err) + } + return ValidatePersistedProviderNames(cfg) +} + +// PreflightProviderWrite also rejects a new spelling that would share a +// case-insensitive credential key with an existing persisted row. +func PreflightProviderWrite(path, name string) error { + if err := PreflightUserConfig(path); err != nil { + return err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read config %s: %w", path, err) + } + var cfg FileConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("invalid config JSON %s: %w", path, err) + } + name = strings.TrimSpace(name) + for _, provider := range cfg.Providers { + existing := strings.TrimSpace(provider.Name) + if sameProviderIdentity(existing, name) && existing != name { + return fmt.Errorf("provider %q already exists as %q; provider names must be unique case-insensitively", name, existing) + } + } + return nil +} + +// PersistedProviderNames returns the exact name of every row in the persisted +// user config, in file order. Callers that must reason about the SET of saved +// rows — e.g. deciding whether removing one row leaves a case variant behind +// that still reads the same credential-store entry — get the raw names here +// rather than re-implementing FileConfig parsing. +func PersistedProviderNames(path string) ([]string, error) { + providers, err := persistedProviders(path) + if err != nil { + return nil, err + } + names := make([]string, 0, len(providers)) + for _, provider := range providers { + names = append(names, strings.TrimSpace(provider.Name)) + } + return names, nil +} + +// persistedProviders reads the provider rows out of the user config at path. +// A missing file is an empty list, not an error: every caller here asks "what +// is already saved?", and "nothing yet" is a legitimate answer. +func persistedProviders(path string) ([]ProviderProfile, error) { + data, err := os.ReadFile(strings.TrimSpace(path)) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read config %s: %w", path, err) + } + var cfg FileConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("invalid config JSON %s: %w", path, err) + } + return cfg.Providers, nil +} + func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) { path = strings.TrimSpace(path) if path == "" { @@ -29,6 +159,14 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC } else if !os.IsNotExist(err) { return FileConfig{}, fmt.Errorf("read config %s: %w", path, err) } + if err := ValidatePersistedProviderNames(cfg); err != nil { + return FileConfig{}, err + } + for _, existing := range cfg.Providers { + if sameProviderIdentity(existing.Name, profile.Name) && strings.TrimSpace(existing.Name) != profile.Name { + return FileConfig{}, fmt.Errorf("provider %q already exists as %q; provider names must be unique case-insensitively", profile.Name, existing.Name) + } + } mergeProvider(&cfg, profile) // mergeProfile deliberately ignores APIKeyStored — during resolve-time @@ -133,8 +271,11 @@ func MarkProviderAPIKeyStored(path string, provider string) error { if err := json.Unmarshal(data, &cfg); err != nil { return fmt.Errorf("invalid config JSON %s: %w", path, err) } + if err := ValidatePersistedProviderNames(cfg); err != nil { + return err + } for index := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), provider) { + if strings.TrimSpace(cfg.Providers[index].Name) == provider { cfg.Providers[index].APIKey = "" cfg.Providers[index].APIKeyEnv = "" cfg.Providers[index].APIKeyStored = true @@ -165,7 +306,7 @@ func SetActiveProvider(path string, name string) (FileConfig, error) { } for _, provider := range cfg.Providers { - if strings.EqualFold(provider.Name, name) { + if strings.TrimSpace(provider.Name) == name { cfg.ActiveProvider = provider.Name if err := writeConfigFile(path, cfg); err != nil { return FileConfig{}, err @@ -197,7 +338,7 @@ func ProviderPersisted(path string, name string) (bool, error) { return false, err } for _, provider := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(provider.Name), name) { + if strings.TrimSpace(provider.Name) == name { return true, nil } } @@ -229,9 +370,13 @@ func RemoveProvider(path string, name string) (FileConfig, error) { return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) } + // Persisted provider identity is exact. Resolution may fold names from + // runtime sources, but config mutations must target the requested row. This + // lookup intentionally precedes validation so an exact removal can repair a + // case-duplicate config; writeConfigFile validates the resulting config. index := -1 for i, provider := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(provider.Name), name) { + if strings.TrimSpace(provider.Name) == name { index = i break } @@ -239,9 +384,22 @@ func RemoveProvider(path string, name string) (FileConfig, error) { if index < 0 { return FileConfig{}, fmt.Errorf("provider %q not found", name) } - removed := cfg.Providers[index] + activeIndex := -1 + activeFoldedIndex := -1 + activeFoldedMatches := 0 + for i, provider := range cfg.Providers { + providerName := strings.TrimSpace(provider.Name) + if providerName == strings.TrimSpace(cfg.ActiveProvider) { + activeIndex = i + } + if sameProviderIdentity(providerName, cfg.ActiveProvider) { + activeFoldedIndex = i + activeFoldedMatches++ + } + } + removedWasActive := activeIndex == index || (activeIndex < 0 && activeFoldedMatches == 1 && activeFoldedIndex == index) cfg.Providers = append(cfg.Providers[:index], cfg.Providers[index+1:]...) - if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(removed.Name)) { + if removedWasActive { cfg.ActiveProvider = "" if len(cfg.Providers) > 0 { cfg.ActiveProvider = cfg.Providers[0].Name @@ -280,22 +438,28 @@ func RenameProvider(path string, oldName string, newName string) (FileConfig, er if err := json.Unmarshal(data, &cfg); err != nil { return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) } + if err := ValidatePersistedProviderNames(cfg); err != nil { + return FileConfig{}, err + } + // oldName is matched exactly, like ProviderPersisted/SetActiveProvider. + // newName collides case-insensitively because the credential store retains + // legacy case-insensitive keys. index := -1 for i, provider := range cfg.Providers { providerName := strings.TrimSpace(provider.Name) - if strings.EqualFold(providerName, oldName) { + if providerName == oldName { index = i continue } - if strings.EqualFold(providerName, newName) { + if sameProviderIdentity(providerName, newName) { return FileConfig{}, fmt.Errorf("provider %q already exists", newName) } } if index < 0 { return FileConfig{}, fmt.Errorf("provider %q not found", oldName) } - if strings.EqualFold(oldName, newName) && cfg.Providers[index].Name == newName { + if sameProviderIdentity(oldName, newName) && cfg.Providers[index].Name == newName { return cfg, nil } @@ -307,7 +471,7 @@ func RenameProvider(path string, oldName string, newName string) (FileConfig, er } keyMigrated = true } - if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) { + if sameProviderIdentity(cfg.ActiveProvider, previousName) { cfg.ActiveProvider = newName } cfg.Providers[index].Name = newName @@ -325,7 +489,7 @@ func RenameProvider(path string, oldName string, newName string) (FileConfig, er // ProviderEdit is a field-level edit of one saved provider, applied by // EditProvider in a single atomic write. Name is the CURRENT profile name -// (matched case-insensitively); NewName renames (case-only renames included). +// (matched exactly); NewName renames (case-only renames included). // Empty BaseURL/Model/APIKey mean "leave unchanged"; Description is applied // VERBATIM (the editor always knows the full desired text, so clearing works). type ProviderEdit struct { @@ -344,8 +508,7 @@ type ProviderEdit struct { // verbatim description. A single write keeps the operation atomic — the // previous rename+upsert+describe sequence could fail halfway and leave // config.json renamed while every in-memory consumer still held the old name — -// and, unlike UpsertProvider's exact-name merge, the case-insensitive match -// here makes a case-only rename (groq -> Groq) an in-place update instead of +// and a case-only rename (groq -> Groq) remains an in-place update instead of // an appended duplicate profile. func EditProvider(path string, edit ProviderEdit) (FileConfig, error) { path = strings.TrimSpace(path) @@ -370,14 +533,19 @@ func EditProvider(path string, edit ProviderEdit) (FileConfig, error) { return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) } + if err := ValidatePersistedProviderNames(cfg); err != nil { + return FileConfig{}, err + } + index := -1 + newIdentity := credstore.NormalizeProvider(newName) for i, provider := range cfg.Providers { providerName := strings.TrimSpace(provider.Name) - if strings.EqualFold(providerName, oldName) { + if providerName == oldName { index = i continue } - if strings.EqualFold(providerName, newName) { + if credstore.NormalizeProvider(providerName) == newIdentity { return FileConfig{}, fmt.Errorf("provider %q already exists", newName) } } @@ -400,7 +568,7 @@ func EditProvider(path string, edit ProviderEdit) (FileConfig, error) { } keyMigrated = true } - if renamed && strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) { + if renamed && sameProviderIdentity(cfg.ActiveProvider, previousName) { cfg.ActiveProvider = newName } @@ -440,7 +608,7 @@ func migrateStoredProviderKey(configPath string, oldName string, newName string) // (groq -> Groq) targets ONE entry: Set(new) rewrites it in place and // Delete(old) would then remove the key that was just "moved". Nothing to // migrate — the existing entry already serves the new name. - if strings.EqualFold(strings.TrimSpace(oldName), strings.TrimSpace(newName)) { + if sameProviderIdentity(oldName, newName) { return nil } store, err := ProviderKeyStoreAt(filepath.Dir(configPath)) @@ -485,8 +653,10 @@ func SetProviderModel(path string, name string, model string) (FileConfig, error return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) } + // Persisted provider identity is exact. Resolution may fold names from + // runtime sources, but config mutations must target the requested row. for index := range cfg.Providers { - if strings.EqualFold(cfg.Providers[index].Name, name) { + if strings.TrimSpace(cfg.Providers[index].Name) == name { cfg.Providers[index].Model = model if err := writeConfigFile(path, cfg); err != nil { return FileConfig{}, err @@ -765,6 +935,9 @@ func NormalizeRecentModels(entries []RecentModelEntry) []RecentModelEntry { } func writeConfigFile(path string, cfg FileConfig) error { + if err := ValidatePersistedProviderNames(cfg); err != nil { + return err + } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("encode config JSON: %w", err) diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go index c66fc26ba..bb560dd55 100644 --- a/internal/config/writer_test.go +++ b/internal/config/writer_test.go @@ -1,8 +1,10 @@ package config import ( + "bytes" "encoding/json" "errors" + "fmt" "io/fs" "os" "os/exec" @@ -31,7 +33,7 @@ func TestSetActiveProviderSwitchesConfiguredProvider(t *testing.T) { }, }, 0o600) - cfg, err := SetActiveProvider(path, " anthropic ") + cfg, err := SetActiveProvider(path, " Anthropic ") if err != nil { t.Fatalf("SetActiveProvider() error = %v", err) } @@ -170,7 +172,7 @@ func TestSetProviderModelUpdatesConfiguredProvider(t *testing.T) { }, }, 0o600) - cfg, err := SetProviderModel(path, " OpenAI ", " gpt-4.1-mini ") + cfg, err := SetProviderModel(path, " openai ", " gpt-4.1-mini ") if err != nil { t.Fatalf("SetProviderModel() error = %v", err) } @@ -594,7 +596,7 @@ func TestRemoveProviderDeletesAndHandsOffActive(t *testing.T) { }, }, 0o600) - cfg, err := RemoveProvider(path, " BETA ") + cfg, err := RemoveProvider(path, " beta ") if err != nil { t.Fatalf("RemoveProvider() error = %v", err) } @@ -917,9 +919,9 @@ func TestEditProviderAppliesRenameFieldsAndDescriptionAtomically(t *testing.T) { // TestEditProviderCaseOnlyRenameUpdatesInPlace: the manager previously skipped // RenameProvider on case-insensitively-equal names and fell into UpsertProvider, -// whose case-SENSITIVE merge appended a duplicate profile. EditProvider matches -// case-insensitively, so a case-only rename is an in-place update and the store -// entry (case-normalized) survives. +// whose case-SENSITIVE merge appended a duplicate profile. EditProvider applies +// NewName to the exact current profile, so a case-only rename is an in-place +// update and the store entry (case-normalized) survives. func TestEditProviderCaseOnlyRenameUpdatesInPlace(t *testing.T) { dir := t.TempDir() t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") @@ -1021,3 +1023,239 @@ func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) { t.Fatalf("config was rewritten by a rejected edit") } } + +func TestSetActiveProviderRequiresExactProviderIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + before := writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "work", + Providers: []ProviderProfile{ + {Name: "work", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"}, + }, + }, 0o600) + + _, err := SetActiveProvider(path, "WORK") + if err == nil || !strings.Contains(err.Error(), `provider "WORK" not found`) { + t.Fatalf("SetActiveProvider() error = %v, want exact-case not-found error", err) + } + after, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("read config: %v", readErr) + } + if string(after) != string(before) { + t.Fatalf("config was rewritten for case-variant provider\nbefore: %s\nafter: %s", before, after) + } +} + +func TestMarkProviderAPIKeyStoredRequiresExactProviderIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + before := writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: "work", APIKeyEnv: "WORK_KEY"}}}, 0o600) + if err := MarkProviderAPIKeyStored(path, "WORK"); err == nil || !strings.Contains(err.Error(), `provider "WORK" not found`) { + t.Fatalf("MarkProviderAPIKeyStored() error = %v, want exact-case not-found", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatal("case-variant mark rewrote config") + } +} + +func TestProviderPersistedRequiresExactProviderIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: "work"}}}, 0o600) + + persisted, err := ProviderPersisted(path, "WORK") + if err != nil { + t.Fatalf("ProviderPersisted() error = %v", err) + } + if persisted { + t.Fatal("ProviderPersisted() = true for case-variant identity, want false") + } +} + +// Same scenario as RemoveProvider/RenameProvider: two rows differing only by +// case must not let SetProviderModel update the wrong one. +func TestSetProviderModelRequiresExactProviderIdentityAmongCaseVariants(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + before := writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "work", + Providers: []ProviderProfile{ + {Name: "work", ProviderKind: ProviderKindOpenAICompatible, Model: "m1"}, + {Name: "WORK", ProviderKind: ProviderKindOpenAICompatible, Model: "m2"}, + }, + }, 0o600) + + _, err := SetProviderModel(path, "WORK", "m2-updated") + assertAmbiguousConfigUnchanged(t, path, before, err, "work", "WORK") +} + +func TestProviderMutatorsHandOffCaseVariantActiveProvider(t *testing.T) { + tests := []struct { + name string + mutate func(string) (FileConfig, error) + wantActive string + wantName string + }{ + {name: "remove", mutate: func(path string) (FileConfig, error) { return RemoveProvider(path, "work") }}, + {name: "rename", mutate: func(path string) (FileConfig, error) { return RenameProvider(path, "work", "office") }, wantActive: "office", wantName: "office"}, + {name: "edit", mutate: func(path string) (FileConfig, error) { + return EditProvider(path, ProviderEdit{Name: "work", NewName: "office", Model: "updated"}) + }, wantActive: "office", wantName: "office"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + writeConfigFixture(t, path, FileConfig{ActiveProvider: "WORK", Providers: []ProviderProfile{{Name: "work", Model: "old"}}}, 0o600) + cfg, err := test.mutate(path) + if err != nil { + t.Fatal(err) + } + if cfg.ActiveProvider != test.wantActive { + t.Fatalf("activeProvider = %q, want %q", cfg.ActiveProvider, test.wantActive) + } + if test.wantName == "" && len(cfg.Providers) != 0 { + t.Fatalf("providers = %+v, want none", cfg.Providers) + } + if test.wantName != "" && (len(cfg.Providers) != 1 || cfg.Providers[0].Name != test.wantName) { + t.Fatalf("providers = %+v, want canonical name %q", cfg.Providers, test.wantName) + } + }) + } +} + +// UpsertProvider merges by exact name, so a config file can end up with two +// rows that differ only by case (e.g. one saved as "work", another later +// saved as "WORK"). RemoveProvider must delete the exact row the caller +// named, not whichever case-variant sorts first. +func TestRemoveProviderRequiresExactProviderIdentityAmongCaseVariants(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "work", + Providers: []ProviderProfile{ + {Name: "work", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"}, + {Name: "WORK", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"}, + }, + }, 0o600) + + cfg, err := RemoveProvider(path, "WORK") + if err != nil { + t.Fatalf("exact removal should repair case duplicates: %v", err) + } + if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "work" || cfg.ActiveProvider != "work" { + t.Fatalf("repaired config = %+v", cfg) + } +} + +func TestRemoveProviderRejectsNonExactCaseDuplicateTarget(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + before := writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: "work"}, {Name: "WORK"}}}, 0o600) + _, err := RemoveProvider(path, "WoRk") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("error = %v, want exact-target not-found error", err) + } + after, readErr := os.ReadFile(path) + if readErr != nil || !bytes.Equal(after, before) { + t.Fatalf("rejected removal rewrote config: readErr=%v", readErr) + } +} + +func TestRemoveProviderRejectsRepairThatRemainsAmbiguous(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + before := writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: "work"}, {Name: "WORK"}, {Name: "Work"}}}, 0o600) + _, err := RemoveProvider(path, "Work") + if err == nil || !strings.Contains(err.Error(), "ambiguous persisted provider names") { + t.Fatalf("error = %v, want resulting-config validation error", err) + } + after, readErr := os.ReadFile(path) + if readErr != nil || !bytes.Equal(after, before) { + t.Fatalf("invalid repair rewrote config: readErr=%v", readErr) + } +} + +func TestRemoveProviderKeepsExactActiveCaseVariant(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "work", + Providers: []ProviderProfile{{Name: "alpha"}, {Name: "work"}, {Name: "WORK"}}, + }, 0o600) + + cfg, err := RemoveProvider(path, "WORK") + if err != nil { + t.Fatal(err) + } + if cfg.ActiveProvider != "work" { + t.Fatalf("activeProvider = %q, want exact surviving row work", cfg.ActiveProvider) + } +} + +// Same scenario as RemoveProvider: two rows differing only by case must not +// let RenameProvider act on the wrong one. +func TestRenameProviderRequiresExactProviderIdentityAmongCaseVariants(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + before := writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "work", + Providers: []ProviderProfile{ + {Name: "work", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"}, + {Name: "WORK", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"}, + }, + }, 0o600) + + _, err := RenameProvider(path, "WORK", "renamed") + assertAmbiguousConfigUnchanged(t, path, before, err, "work", "WORK") +} + +func TestEditProviderRequiresExactProviderIdentityAmongCaseVariants(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + before := writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "work", + Providers: []ProviderProfile{ + {Name: "WORK", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://upper.example.com/v1", Model: "upper"}, + {Name: "work", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://lower.example.com/v1", Model: "lower"}, + }, + }, 0o600) + + _, err := EditProvider(path, ProviderEdit{Name: "WORK", NewName: "renamed", Model: "updated"}) + assertAmbiguousConfigUnchanged(t, path, before, err, "WORK", "work") +} + +func assertAmbiguousConfigUnchanged(t *testing.T, path string, before []byte, err error, first, second string) { + t.Helper() + want := fmt.Sprintf("ambiguous persisted provider names %q and %q differ only by case; rename or remove one row in config.json", first, second) + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + after, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(after, before) { + t.Fatalf("ambiguous mutation rewrote config\nbefore: %s\nafter: %s", before, after) + } +} + +// TestValidatePersistedProviderNamesRejectsExactDuplicates covers jatmn's #725 +// finding: the validator only rejected a repeated folded name when the +// SPELLINGS differed, so two rows literally named "work" passed. That breaks +// the same one-credential-per-folded-name invariant the case check protects — +// resolver merging coalesces the rows, and plaintext-key migration writes both +// values into one normalized credential-store entry, overwriting the first key. +func TestValidatePersistedProviderNamesRejectsExactDuplicates(t *testing.T) { + for name, providers := range map[string][]ProviderProfile{ + "identical spellings": {{Name: "work"}, {Name: "work"}}, + "same after trimming": {{Name: "work"}, {Name: " work "}}, + } { + t.Run(name, func(t *testing.T) { + err := ValidatePersistedProviderNames(FileConfig{Providers: providers}) + if err == nil { + t.Fatal("a repeated folded provider identity must be rejected") + } + if want := `duplicate persisted provider name "work"`; !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want it to contain %q", err, want) + } + }) + } + if err := ValidatePersistedProviderNames(FileConfig{Providers: []ProviderProfile{{Name: "work"}, {Name: "fast"}}}); err != nil { + t.Fatalf("distinct names must validate: %v", err) + } +} diff --git a/internal/credstore/credstore.go b/internal/credstore/credstore.go index c95036383..25103936d 100644 --- a/internal/credstore/credstore.go +++ b/internal/credstore/credstore.go @@ -309,5 +309,16 @@ func (s *Store) lockPath() string { return s.file + ".lock" } func filepathDir(path string) string { return filepath.Dir(path) } func normalizeProvider(provider string) string { + return NormalizeProvider(provider) +} + +// NormalizeProvider is the credential-store's provider-name equivalence rule: +// entries are keyed by the trimmed, lowercased name. Callers that decide +// whether two provider spellings share one stored secret (e.g. removing a +// case-variant row while a sibling survives) must compare with THIS function +// rather than strings.EqualFold — the two relations are not the same. Unicode +// case folding equates "s" and "ſ", strings.ToLower does not, so an EqualFold +// comparison can promise a survivor access to a key it cannot look up. +func NormalizeProvider(provider string) string { return strings.ToLower(strings.TrimSpace(provider)) } From 00b9e87dd81d958a9ce82b7217d6d64add095980 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 12 Aug 2026 10:56:29 +0000 Subject: [PATCH 02/17] test(config): cover case-variant upsert rejection Amp-Thread-ID: https://ampcode.com/threads/T-019ff599-6536-705f-9cd1-54ca8c27b5c6 Co-authored-by: Pierre Bruno --- internal/config/credentials.go | 33 ++++++++++----------------------- internal/config/writer_test.go | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/internal/config/credentials.go b/internal/config/credentials.go index d4e5d6816..6650e9cfc 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -86,6 +86,12 @@ func ClearProviderKeyStored(path, provider string) (bool, error) { if path == "" || provider == "" { return false, nil } + return clearProviderKeyStoredWhere(path, func(name string) bool { + return strings.TrimSpace(name) == provider + }) +} + +func clearProviderKeyStoredWhere(path string, matches func(string) bool) (bool, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -99,7 +105,7 @@ func ClearProviderKeyStored(path, provider string) (bool, error) { } changed := false for index := range cfg.Providers { - if strings.TrimSpace(cfg.Providers[index].Name) == provider && cfg.Providers[index].APIKeyStored { + if matches(cfg.Providers[index].Name) && cfg.Providers[index].APIKeyStored { cfg.Providers[index].APIKeyStored = false changed = true } @@ -122,29 +128,10 @@ func ClearProviderKeyStoredCaseVariants(path, provider string) (bool, error) { if path == "" || provider == "" { return false, nil } - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, fmt.Errorf("read config %s: %w", path, err) - } - var cfg FileConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return false, fmt.Errorf("invalid config JSON %s: %w", path, err) - } - changed := false providerIdentity := credstore.NormalizeProvider(provider) - for index := range cfg.Providers { - if credstore.NormalizeProvider(cfg.Providers[index].Name) == providerIdentity && cfg.Providers[index].APIKeyStored { - cfg.Providers[index].APIKeyStored = false - changed = true - } - } - if !changed { - return false, nil - } - return true, writeConfigFile(path, cfg) + return clearProviderKeyStoredWhere(path, func(name string) bool { + return credstore.NormalizeProvider(name) == providerIdentity + }) } // MigratePlaintextProviderKeys moves any inline plaintext API key in the config at diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go index bb560dd55..b52e14bfa 100644 --- a/internal/config/writer_test.go +++ b/internal/config/writer_test.go @@ -1024,6 +1024,28 @@ func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) { } } +func TestUpsertProviderRejectsCaseVariantWithoutRewritingConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + before := writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "work", + Providers: []ProviderProfile{ + {Name: "work", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://work.example/v1", Model: "m1"}, + }, + }, 0o600) + + _, err := UpsertProvider(path, ProviderProfile{Name: "WORK", Model: "m2"}, false) + if err == nil || !strings.Contains(err.Error(), `provider "WORK" already exists as "work"`) { + t.Fatalf("UpsertProvider() error = %v, want case-variant collision", err) + } + after, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("read config: %v", readErr) + } + if !bytes.Equal(after, before) { + t.Fatalf("rejected upsert rewrote config\nbefore: %s\nafter: %s", before, after) + } +} + func TestSetActiveProviderRequiresExactProviderIdentity(t *testing.T) { path := filepath.Join(t.TempDir(), "zero.json") before := writeConfigFixture(t, path, FileConfig{ From 7b30aafa4dfa3eac6c0aee59b5200270cfef0dcb Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Wed, 12 Aug 2026 20:55:28 +0200 Subject: [PATCH 03/17] fix provider identity lifecycle boundaries Co-authored-by: Pierre Bruno --- internal/cli/auth.go | 11 ++- internal/cli/auth_test.go | 76 +++++++++++++++++++ internal/cli/provider_onboarding.go | 18 ++++- internal/cli/provider_onboarding_test.go | 92 ++++++++++++++++++++++ internal/cli/provider_setup.go | 3 + internal/cli/setup.go | 3 + internal/cli/setup_test.go | 44 +++++++++++ internal/config/credentials.go | 3 + internal/config/credentials_test.go | 24 ++++++ internal/config/resolver_test.go | 13 ++++ internal/config/writer.go | 23 +++--- internal/config/writer_test.go | 48 +++++++----- internal/tui/provider_manager.go | 47 ++++++++---- internal/tui/provider_manager_test.go | 97 ++++++++++++++++++++++++ internal/tui/provider_wizard.go | 12 ++- internal/tui/provider_wizard_test.go | 73 ++++++++++++++++-- 16 files changed, 533 insertions(+), 54 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index f3ecdcc42..46302e7c0 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -438,6 +438,13 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe return writeExecUsageError(stderr, "usage: zero auth logout ") } provider := parsed.positional[0] + configPath := "" + if path, pathErr := deps.userConfigPath(); pathErr == nil { + configPath = path + if err := config.PreflightUserConfig(configPath); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + } manager, err := newAuthManager(deps, stdout) if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) @@ -453,8 +460,8 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe if keyErr != nil { return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash) } - if configPath, perr := deps.userConfigPath(); perr == nil { - if _, clearErr := config.ClearProviderKeyStored(configPath, provider); clearErr != nil { + if configPath != "" { + if _, clearErr := config.ClearProviderKeyStoredCaseVariants(configPath, provider); clearErr != nil { return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash) } } diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 9b1ba0fb5..abddcd6ae 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -321,3 +321,79 @@ func readCLIConfigFixture(t *testing.T, path string) config.FileConfig { } return cfg } + +func TestRunAuthLogoutClearsCaseVariantStoredMarker(t *testing.T) { + withAuthStore(t) + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCLIUserConfigRoot(t) + configPath, err := config.DefaultUserConfigPath() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(`{"providers":[{"name":"work","apiKeyStored":true}]}`), 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStore() + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "sk-old"); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }} + if code := runWithDeps([]string{"auth", "logout", "WORK"}, &stdout, &stderr, deps); code != exitSuccess { + t.Fatalf("logout failed: code=%d stderr=%s", code, stderr.String()) + } + if _, ok, getErr := store.Get("work"); getErr != nil || ok { + t.Fatalf("stored key still present: ok=%v err=%v", ok, getErr) + } + cfg := readCLIConfigFixture(t, configPath) + if cfg.Providers[0].APIKeyStored { + t.Fatal("case-variant logout left apiKeyStored set") + } +} + +func TestRunAuthLogoutRejectsAmbiguousConfigBeforeCredentialDeletion(t *testing.T) { + withAuthStore(t) + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCLIUserConfigRoot(t) + configPath, err := config.DefaultUserConfigPath() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatal(err) + } + seed := []byte(`{"providers":[{"name":"work","apiKeyStored":true},{"name":"WORK","apiKeyStored":true}]}`) + if err := os.WriteFile(configPath, seed, 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStore() + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "sk-shared"); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }} + if code := runWithDeps([]string{"auth", "logout", "WORK"}, &stdout, &stderr, deps); code != exitCrash { + t.Fatalf("logout exit = %d, want validation failure", code) + } + if key, ok, getErr := store.Get("work"); getErr != nil || !ok || key != "sk-shared" { + t.Fatalf("shared credential changed before rejection: %q,%v,%v", key, ok, getErr) + } + after, readErr := os.ReadFile(configPath) + if readErr != nil { + t.Fatal(readErr) + } + if string(after) != string(seed) { + t.Fatal("rejected logout rewrote ambiguous config") + } +} diff --git a/internal/cli/provider_onboarding.go b/internal/cli/provider_onboarding.go index 08650c4a8..320a1d2e4 100644 --- a/internal/cli/provider_onboarding.go +++ b/internal/cli/provider_onboarding.go @@ -113,7 +113,7 @@ func activeProviderEnvOverride(getenv func(string) string, selected string) stri return "" } override := strings.TrimSpace(getenv(config.ActiveProviderEnv)) - if override == "" || strings.EqualFold(override, strings.TrimSpace(selected)) { + if override == "" || config.SameProviderIdentity(override, selected) { return "" } return override @@ -421,7 +421,10 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps // Delete the key from the store BESIDE the config being edited — the same // store setup/rename write to — not the default-path store, so a // non-default config path cannot leave the encrypted key behind. - keyRemoved, keyErr := removeStoredProviderKeyAt(configPath, name) + keyRemoved, keyErr := false, error(nil) + if !providerIdentitySurvives(cfg.Providers, name) { + keyRemoved, keyErr = removeStoredProviderKeyAt(configPath, name) + } if options.json { payload := map[string]any{ "removed": name, @@ -473,6 +476,15 @@ func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) return store.Delete(provider) } +func providerIdentitySurvives(providers []config.ProviderProfile, removedName string) bool { + for _, provider := range providers { + if config.SameProviderIdentity(provider.Name, removedName) { + return true + } + } + return false +} + // runProvidersRename renames a saved provider profile, migrating its stored // API key and the activeProvider pointer along with it (config.RenameProvider). func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { @@ -526,7 +538,7 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps func providerResolvedByName(providers []config.ProviderProfile, name string) bool { name = strings.TrimSpace(name) for _, provider := range providers { - if strings.EqualFold(strings.TrimSpace(provider.Name), name) { + if config.SameProviderIdentity(provider.Name, name) { return true } } diff --git a/internal/cli/provider_onboarding_test.go b/internal/cli/provider_onboarding_test.go index 6118939d8..bde220141 100644 --- a/internal/cli/provider_onboarding_test.go +++ b/internal/cli/provider_onboarding_test.go @@ -480,3 +480,95 @@ func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) { t.Fatalf("stored key must be deleted from the store beside the config") } } + +func TestRunProvidersRemoveKeepsSharedCredentialForCaseVariantSurvivor(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + seed := []byte(`{"activeProvider":"work","providers":[{"name":"work","apiKeyStored":true},{"name":"WORK","apiKeyStored":true}]}`) + if err := os.WriteFile(configPath, seed, 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "sk-shared"); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }} + if code := runWithDeps([]string{"providers", "remove", "WORK", "--json"}, &stdout, &stderr, deps); code != exitSuccess { + t.Fatalf("remove failed: code=%d stderr=%s", code, stderr.String()) + } + cfg := readFileConfig(t, configPath) + if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "work" || !cfg.Providers[0].APIKeyStored { + t.Fatalf("survivor = %+v, want credentialed work row", cfg.Providers) + } + if key, ok, getErr := store.Get("work"); getErr != nil || !ok || key != "sk-shared" { + t.Fatalf("shared key = %q,%v,%v; want sk-shared,true,nil", key, ok, getErr) + } + var payload struct { + KeyRemoved bool `json:"keyRemoved"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.KeyRemoved { + t.Fatal("remove reported deleting a credential still owned by the survivor") + } +} + +func TestRunProvidersUseMatchesCredentialIdentityButNotUnicodeCaseFold(t *testing.T) { + t.Run("case variant selects persisted spelling", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + writeProviderOnboardingConfig(t, configPath, config.FileConfig{ + ActiveProvider: "fast", + Providers: []config.ProviderProfile{ + {Name: "OpenAI", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}, + {Name: "fast", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}, + }, + }) + var stdout, stderr bytes.Buffer + if code := runWithDeps([]string{"providers", "use", "openai"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitSuccess { + t.Fatalf("use failed: code=%d stderr=%s", code, stderr.String()) + } + if active := readFileConfig(t, configPath).ActiveProvider; active != "OpenAI" { + t.Fatalf("active provider = %q, want persisted spelling OpenAI", active) + } + }) + + t.Run("environment provider accepts case variant", func(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "sk-env") + configPath := filepath.Join(t.TempDir(), "config.json") + writeProviderOnboardingConfig(t, configPath, config.FileConfig{}) + var stdout, stderr bytes.Buffer + if code := runWithDeps([]string{"providers", "use", "OpenAI"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitSuccess { + t.Fatalf("environment use failed: code=%d stderr=%s", code, stderr.String()) + } + }) + + t.Run("long s is not plain s", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + writeProviderOnboardingConfig(t, configPath, config.FileConfig{ + ActiveProvider: "s", + Providers: []config.ProviderProfile{{Name: "s", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}}, + }) + before, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if code := runWithDeps([]string{"providers", "use", "ſ"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitCrash { + t.Fatalf("use exit = %d, want crash for distinct identity; stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatal("distinct Unicode identity request rewrote config") + } + }) +} diff --git a/internal/cli/provider_setup.go b/internal/cli/provider_setup.go index de13f26ce..dc95bc1d9 100644 --- a/internal/cli/provider_setup.go +++ b/internal/cli/provider_setup.go @@ -53,6 +53,9 @@ func runProvidersAdd(args []string, stdout io.Writer, stderr io.Writer, deps app if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } + if err := config.PreflightProviderWrite(configPath, profile.Name); err != nil { + return writeAppError(stderr, err.Error(), exitCrash) + } // Persist with the key moved into the encrypted credential store (capture flip); // the local profile keeps the key for the verification build below. cfg, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), options.setActive) diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 766cea69b..4ee8c89c3 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -264,6 +264,9 @@ func saveSetupProvider(deps appDeps, selection tui.SetupSelection, options setup if err != nil { return tui.SetupResult{}, err } + if err := config.PreflightProviderWrite(configPath, profile.Name); err != nil { + return tui.SetupResult{}, err + } // Persist with the key moved into the encrypted credential store (capture flip); // the returned profile keeps the key for this run's immediate use. if _, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), true); err != nil { diff --git a/internal/cli/setup_test.go b/internal/cli/setup_test.go index 905ba7f23..e0fbb85d0 100644 --- a/internal/cli/setup_test.go +++ b/internal/cli/setup_test.go @@ -2,6 +2,7 @@ package cli import ( "context" + "os" "path/filepath" "reflect" "strings" @@ -367,3 +368,46 @@ func TestVerifySetupProviderDistinguishesMissingFromRejectedKey(t *testing.T) { t.Fatal("a keyless local provider should still be probed") } } + +func TestSaveSetupProviderRejectsCaseVariantBeforeCredentialCapture(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + writeProviderOnboardingConfig(t, configPath, config.FileConfig{ + ActiveProvider: "work", + Providers: []config.ProviderProfile{{Name: "work", APIKeyStored: true}}, + }) + before, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "OLD"); err != nil { + t.Fatal(err) + } + + _, err = saveSetupProvider(appDeps{ + userConfigPath: func() (string, error) { return configPath, nil }, + }, tui.SetupSelection{ + CatalogID: "ollama-cloud", + Name: "WORK", + Model: "qwen3-coder:480b", + APIKey: "NEW", + }, setupSaveOptions{}) + if err == nil || !strings.Contains(err.Error(), `provider "WORK" already exists as "work"`) { + t.Fatalf("saveSetupProvider() error = %v, want case-variant collision", err) + } + after, readErr := os.ReadFile(configPath) + if readErr != nil { + t.Fatal(readErr) + } + if string(after) != string(before) { + t.Fatalf("rejected setup rewrote config\nbefore: %s\nafter: %s", before, after) + } + if key, ok, getErr := store.Get("work"); getErr != nil || !ok || key != "OLD" { + t.Fatalf("existing credential = %q,%v,%v; want OLD,true,nil", key, ok, getErr) + } +} diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 6650e9cfc..23f48e1f3 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -156,6 +156,9 @@ func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error) if err := json.Unmarshal(data, &cfg); err != nil { return 0, fmt.Errorf("invalid config JSON %s: %w", path, err) } + if err := ValidatePersistedProviderNames(cfg); err != nil { + return 0, err + } migrated := 0 for index := range cfg.Providers { profile := &cfg.Providers[index] diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go index 7dc9aacb7..3cea72605 100644 --- a/internal/config/credentials_test.go +++ b/internal/config/credentials_test.go @@ -125,6 +125,30 @@ func TestMigrateLeavesKeyWhenStoreSetFails(t *testing.T) { } } +func TestMigratePlaintextProviderKeysValidatesBeforeStoreWrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + before := []byte(`{"providers":[{"name":"","apiKey":"sk-implicit"},{"name":"openai","apiKey":"sk-openai"}]}`) + if err := os.WriteFile(path, before, 0o600); err != nil { + t.Fatal(err) + } + store := &fakeKeySetter{keys: map[string]string{}} + + n, err := MigratePlaintextProviderKeys(path, store) + if err == nil || !strings.Contains(err.Error(), "persisted provider name cannot be empty") { + t.Fatalf("migrate = %d,%v; want validation error", n, err) + } + if n != 0 || len(store.keys) != 0 { + t.Fatalf("invalid config mutated credential store: migrated=%d keys=%v", n, store.keys) + } + after, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if string(after) != string(before) { + t.Fatalf("invalid config was rewritten\nbefore: %s\nafter: %s", before, after) + } +} + func TestClearProviderKeyStored(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.json") diff --git a/internal/config/resolver_test.go b/internal/config/resolver_test.go index 0bbfb78cd..e2bc9f20a 100644 --- a/internal/config/resolver_test.go +++ b/internal/config/resolver_test.go @@ -2354,3 +2354,16 @@ func TestResolvePreservesSoleOpenRouterCaseVariant(t *testing.T) { t.Fatalf("active provider name = %q, want preserved OpenRouter", resolved.ActiveProvider) } } + +func TestResolveRejectsBlankPersistedNameBeforeImplicitOpenAIIdentity(t *testing.T) { + path := writeConfig(t, `{ + "providers": [ + {"name":"","providerKind":"openai","model":"gpt-4.1"}, + {"name":"openai","providerKind":"openai","model":"gpt-4.1"} + ] + }`) + _, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}}) + if err == nil || !strings.Contains(err.Error(), "persisted provider name cannot be empty") { + t.Fatalf("Resolve() error = %v, want blank persisted-name rejection", err) + } +} diff --git a/internal/config/writer.go b/internal/config/writer.go index c4e7c9091..e71e0f217 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -12,21 +12,24 @@ import ( "github.com/Gitlawb/zero/internal/providercatalog" ) -// ValidatePersistedProviderNames rejects user-config rows that share the same -// case-insensitive identity. Credential-store keys are case-insensitive, so -// allowing both rows would make writes and deletes affect a shared secret. +// ValidatePersistedProviderNames rejects empty names and user-config rows that +// share the credential store's normalized identity. Allowing either would make +// resolver defaults or credential writes and deletes affect another row. // This validator intentionally applies only to raw persisted user config, not // to profiles merged from project, environment, or provider-command layers. // -// A repeated folded identity is rejected whether or not the spellings differ. -// Exact duplicates are just as broken as case variants: resolver merging -// silently coalesces the rows, and plaintext-key migration writes both values -// into the same normalized credential-store entry, so the second row's key -// overwrites the first. +// A repeated normalized identity is rejected whether or not the spellings +// differ. Exact duplicates are just as broken as case variants: resolver +// merging coalesces the rows, and plaintext-key migration writes both values +// into the same credential-store entry, so the second row's key overwrites the +// first. func ValidatePersistedProviderNames(cfg FileConfig) error { seen := make(map[string]string, len(cfg.Providers)) for _, provider := range cfg.Providers { name := strings.TrimSpace(provider.Name) + if name == "" { + return fmt.Errorf("persisted provider name cannot be empty; name the provider explicitly in config.json") + } folded := credstore.NormalizeProvider(name) previous, ok := seen[folded] if ok && previous == name { @@ -306,7 +309,7 @@ func SetActiveProvider(path string, name string) (FileConfig, error) { } for _, provider := range cfg.Providers { - if strings.TrimSpace(provider.Name) == name { + if sameProviderIdentity(provider.Name, name) { cfg.ActiveProvider = provider.Name if err := writeConfigFile(path, cfg); err != nil { return FileConfig{}, err @@ -338,7 +341,7 @@ func ProviderPersisted(path string, name string) (bool, error) { return false, err } for _, provider := range cfg.Providers { - if strings.TrimSpace(provider.Name) == name { + if sameProviderIdentity(provider.Name, name) { return true, nil } } diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go index b52e14bfa..d5cf46666 100644 --- a/internal/config/writer_test.go +++ b/internal/config/writer_test.go @@ -1046,25 +1046,22 @@ func TestUpsertProviderRejectsCaseVariantWithoutRewritingConfig(t *testing.T) { } } -func TestSetActiveProviderRequiresExactProviderIdentity(t *testing.T) { +func TestSetActiveProviderUsesCredentialIdentityWithoutUnicodeFolding(t *testing.T) { path := filepath.Join(t.TempDir(), "zero.json") - before := writeConfigFixture(t, path, FileConfig{ - ActiveProvider: "work", + writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "ſ", Providers: []ProviderProfile{ - {Name: "work", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"}, + {Name: "s", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"}, + {Name: "ſ", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"}, }, }, 0o600) - _, err := SetActiveProvider(path, "WORK") - if err == nil || !strings.Contains(err.Error(), `provider "WORK" not found`) { - t.Fatalf("SetActiveProvider() error = %v, want exact-case not-found error", err) - } - after, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatalf("read config: %v", readErr) + cfg, err := SetActiveProvider(path, "S") + if err != nil { + t.Fatalf("SetActiveProvider() error = %v", err) } - if string(after) != string(before) { - t.Fatalf("config was rewritten for case-variant provider\nbefore: %s\nafter: %s", before, after) + if cfg.ActiveProvider != "s" { + t.Fatalf("ActiveProvider = %q, want exact persisted spelling s", cfg.ActiveProvider) } } @@ -1083,16 +1080,23 @@ func TestMarkProviderAPIKeyStoredRequiresExactProviderIdentity(t *testing.T) { } } -func TestProviderPersistedRequiresExactProviderIdentity(t *testing.T) { +func TestProviderPersistedUsesCredentialIdentityWithoutUnicodeFolding(t *testing.T) { path := filepath.Join(t.TempDir(), "zero.json") - writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: "work"}}}, 0o600) + writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: "s"}}}, 0o600) - persisted, err := ProviderPersisted(path, "WORK") + persisted, err := ProviderPersisted(path, "S") if err != nil { t.Fatalf("ProviderPersisted() error = %v", err) } + if !persisted { + t.Fatal("ProviderPersisted() = false for case-variant credential identity") + } + persisted, err = ProviderPersisted(path, "ſ") + if err != nil { + t.Fatalf("ProviderPersisted(long-s) error = %v", err) + } if persisted { - t.Fatal("ProviderPersisted() = true for case-variant identity, want false") + t.Fatal("ProviderPersisted() conflated s with Unicode long-s") } } @@ -1281,3 +1285,13 @@ func TestValidatePersistedProviderNamesRejectsExactDuplicates(t *testing.T) { t.Fatalf("distinct names must validate: %v", err) } } + +func TestValidatePersistedProviderNamesRejectsImplicitOpenAICollision(t *testing.T) { + err := ValidatePersistedProviderNames(FileConfig{Providers: []ProviderProfile{ + {Name: ""}, + {Name: "openai"}, + }}) + if err == nil || !strings.Contains(err.Error(), "persisted provider name cannot be empty") { + t.Fatalf("error = %v, want empty persisted-provider name rejection", err) + } +} diff --git a/internal/tui/provider_manager.go b/internal/tui/provider_manager.go index 6816074d7..51303ec8e 100644 --- a/internal/tui/provider_manager.go +++ b/internal/tui/provider_manager.go @@ -364,7 +364,7 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { } activeAfter = cfg.ActiveProvider notes = []string{"Deleted " + name + "."} - cleanup = providerManagerCleanupCmd(m.userConfigPath, row.profile) + cleanup = providerManagerCleanupCmd(m.userConfigPath, row.profile, !providerIdentitySurvives(cfg.Providers, name)) } else { // Env-derived providers have no persisted profile or credential to // delete. Keep this path session-only. @@ -378,9 +378,9 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { // must not replace the resolved/filtered savedProviders wholesale. m.savedProviders = removeSavedProvider(m.savedProviders, name) - if strings.EqualFold(strings.TrimSpace(m.providerName), strings.TrimSpace(name)) { + if config.SameProviderIdentity(m.providerName, name) { notes = append(notes, "This session keeps running on it until you switch.") - } else if activeAfter != "" && !strings.EqualFold(activeAfter, name) { + } else if activeAfter != "" && !config.SameProviderIdentity(activeAfter, name) { notes = append(notes, "Active provider: "+activeAfter+".") } @@ -398,7 +398,7 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { func removeSavedProvider(saved []config.ProviderProfile, name string) []config.ProviderProfile { kept := saved[:0] for _, profile := range saved { - if strings.EqualFold(strings.TrimSpace(profile.Name), strings.TrimSpace(name)) { + if strings.TrimSpace(profile.Name) == strings.TrimSpace(name) { continue } kept = append(kept, profile) @@ -406,6 +406,15 @@ func removeSavedProvider(saved []config.ProviderProfile, name string) []config.P return kept } +func providerIdentitySurvives(providers []config.ProviderProfile, removedName string) bool { + for _, provider := range providers { + if config.SameProviderIdentity(provider.Name, removedName) { + return true + } + } + return false +} + // providerManagerCleanupMsg reports the off-thread half of a delete: the // stored-key removal outcome and the OAuth-login hint. type providerManagerCleanupMsg struct { @@ -417,17 +426,19 @@ type providerManagerCleanupMsg struct { // reads the token store — blocking work the confirm keypress must not wait on. // A failed key delete is surfaced rather than letting a lingering secret read // as a clean removal. -func providerManagerCleanupCmd(configPath string, profile config.ProviderProfile) tea.Cmd { +func providerManagerCleanupCmd(configPath string, profile config.ProviderProfile, deleteStoredKey bool) tea.Cmd { name := profile.Name catalogID := profile.CatalogID return func() tea.Msg { notes := []string{} - keyStore, storeErr := providerKeyStoreForPath(configPath) - if storeErr == nil { - _, storeErr = keyStore.Delete(name) - } - if storeErr != nil { - notes = append(notes, "Warning: its stored API key could not be deleted ("+storeErr.Error()+").") + if deleteStoredKey { + keyStore, storeErr := providerKeyStoreForPath(configPath) + if storeErr == nil { + _, storeErr = keyStore.Delete(name) + } + if storeErr != nil { + notes = append(notes, "Warning: its stored API key could not be deleted ("+storeErr.Error()+").") + } } if login, ok := oauthLoginName(config.ProviderProfile{Name: name, CatalogID: catalogID}); ok { notes = append(notes, "OAuth login kept — remove with `zero auth logout "+login+"`.") @@ -589,6 +600,10 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { wizard.err = "no user config path — cannot save" return m, nil } + if err := config.PreflightUserConfig(m.userConfigPath); err != nil { + wizard.err = err.Error() + return m, nil + } oldName := strings.TrimSpace(wizard.editOriginal.Name) persisted, err := config.ProviderPersisted(m.userConfigPath, oldName) if err != nil { @@ -631,7 +646,7 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { // Keep the live session's identity in sync with a rename of the provider it // is running on: the exported ZERO_PROVIDER must resolve for spawned children. - if strings.EqualFold(strings.TrimSpace(m.providerName), oldName) { + if config.SameProviderIdentity(m.providerName, oldName) { m.providerName = newName m.providerProfile.Name = newName config.SetActiveProviderEnv(newName) @@ -649,7 +664,7 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { // liveName is the session's provider AFTER any rename sync, so a single // comparison against the edited profile's final name suffices. func providerEditRestartNote(liveName string, editedName string) string { - if strings.EqualFold(strings.TrimSpace(liveName), strings.TrimSpace(editedName)) { + if config.SameProviderIdentity(liveName, editedName) { return " Press Enter on it to apply the changes to this session." } return "" @@ -659,7 +674,7 @@ func providerEditRestartNote(liveName string, editedName string) string { // in-memory saved list without wholesale replacement (see saveManagerEdit). func applySavedProviderEdit(saved []config.ProviderProfile, oldName string, edit config.ProviderEdit) []config.ProviderProfile { for index := range saved { - if !strings.EqualFold(strings.TrimSpace(saved[index].Name), strings.TrimSpace(oldName)) { + if strings.TrimSpace(saved[index].Name) != strings.TrimSpace(oldName) { continue } profile := &saved[index] @@ -691,7 +706,7 @@ func applySavedProviderEdit(saved []config.ProviderProfile, oldName string, edit // in-memory saved list (replace by name, else append). func upsertSavedProviderProfile(saved []config.ProviderProfile, profile config.ProviderProfile) []config.ProviderProfile { for index := range saved { - if strings.EqualFold(strings.TrimSpace(saved[index].Name), strings.TrimSpace(profile.Name)) { + if strings.TrimSpace(saved[index].Name) == strings.TrimSpace(profile.Name) { saved[index] = profile return saved } @@ -727,7 +742,7 @@ func (wizard *providerWizardState) renderManageStep(width int) []string { marker = surface(zeroTheme.accent).Render("❯ ") } active := "" - if strings.EqualFold(strings.TrimSpace(row.profile.Name), strings.TrimSpace(wizard.manageActiveName)) { + if strings.TrimSpace(row.profile.Name) == strings.TrimSpace(wizard.manageActiveName) { active = surface(zeroTheme.accent).Render(" ● active") } name := padProviderManagerCell(row.profile.Name, nameWidth) diff --git a/internal/tui/provider_manager_test.go b/internal/tui/provider_manager_test.go index ddc2429fd..ae6fa78b1 100644 --- a/internal/tui/provider_manager_test.go +++ b/internal/tui/provider_manager_test.go @@ -645,3 +645,100 @@ func TestProviderManagerCredStateFallsThroughStaleMarker(t *testing.T) { t.Fatalf("expected stored key missing with no fallback, got %q", state) } } + +func TestProviderManagerRemoveKeepsSharedCredentialForCaseVariantSurvivor(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + profiles := []config.ProviderProfile{ + {Name: "work", APIKeyStored: true}, + {Name: "WORK", APIKeyStored: true}, + } + if err := os.WriteFile(configPath, []byte(`{"activeProvider":"work","providers":[{"name":"work","apiKeyStored":true},{"name":"WORK","apiKeyStored":true}]}`), 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "sk-shared"); err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + ProviderName: "work", + ProviderProfile: profiles[0], + SavedProviders: profiles, + UserConfigPath: configPath, + }) + m, _ = m.openProviderManager() + m.providerWizard.manageCursor = 1 + next, cmd := m.deleteManagerSelection() + next = drainProviderManagerCmds(t, next, cmd) + + cfg := readManagerConfig(t, configPath) + if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "work" || !cfg.Providers[0].APIKeyStored { + t.Fatalf("survivor = %+v, want credentialed work row", cfg.Providers) + } + if len(next.savedProviders) != 1 || next.savedProviders[0].Name != "work" { + t.Fatalf("in-memory survivor = %+v, want work", next.savedProviders) + } + if key, ok, getErr := store.Get("work"); getErr != nil || !ok || key != "sk-shared" { + t.Fatalf("shared key = %q,%v,%v; want sk-shared,true,nil", key, ok, getErr) + } +} + +func TestProviderManagerKeepsDistinctUnicodeLiveProviderOnOtherRowMutation(t *testing.T) { + newModelWithRows := func(t *testing.T) model { + t.Helper() + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + profiles := []config.ProviderProfile{ + {Name: "s", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://s.example/v1", Model: "s-model"}, + {Name: "ſ", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://long-s.example/v1", Model: "long-s-model"}, + } + path := filepath.Join(t.TempDir(), "config.json") + data, err := json.Marshal(config.FileConfig{ActiveProvider: "ſ", Providers: profiles}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + ProviderName: "ſ", + ProviderProfile: profiles[1], + SavedProviders: profiles, + UserConfigPath: path, + }) + m, _ = m.openProviderManager() + return m + } + + t.Run("edit s", func(t *testing.T) { + t.Setenv(config.ActiveProviderEnv, "ſ") + m := newModelWithRows(t) + m.providerWizard.beginProviderEdit(m.savedProviders[0]) + m.providerWizard.editDraft.Model = "s-updated" + next, _ := m.saveManagerEdit() + if next.providerName != "ſ" || next.providerProfile.Name != "ſ" { + t.Fatalf("editing s rewrote live long-s identity: name=%q profile=%q", next.providerName, next.providerProfile.Name) + } + if got := os.Getenv(config.ActiveProviderEnv); got != "ſ" { + t.Fatalf("%s = %q, want long-s unchanged", config.ActiveProviderEnv, got) + } + if next.savedProviders[0].Model != "s-updated" || next.savedProviders[1].Name != "ſ" { + t.Fatalf("wrong in-memory edit target: %+v", next.savedProviders) + } + }) + + t.Run("remove s", func(t *testing.T) { + m := newModelWithRows(t) + m.providerWizard.manageCursor = 0 + next, _ := m.deleteManagerSelection() + if next.providerName != "ſ" { + t.Fatalf("removing s changed live long-s provider to %q", next.providerName) + } + if len(next.savedProviders) != 1 || next.savedProviders[0].Name != "ſ" { + t.Fatalf("wrong in-memory removal target: %+v", next.savedProviders) + } + }) +} diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index 8f4ee8bb3..40388a883 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -1261,6 +1261,10 @@ func (m model) applyProviderWizard() (model, tea.Cmd) { nextProvider = built } if strings.TrimSpace(m.userConfigPath) != "" { + if err := config.PreflightProviderWrite(m.userConfigPath, profile.Name); err != nil { + wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{profile.APIKey, runtimeProfile.APIKey}}) + return m, nil + } // Capture flip: move the freshly entered key into the encrypted credential // store before persisting, so config.json never holds the cleartext. The // provider was already built above from runtimeProfile, which has the key. @@ -1328,11 +1332,17 @@ func (m model) applyManageKeyChoice() (model, tea.Cmd) { wizard.step = providerWizardStepCredential return m, nil case 2: // Remove + if strings.TrimSpace(m.userConfigPath) != "" { + if err := config.PreflightUserConfig(m.userConfigPath); err != nil { + wizard.err = redaction.RedactString(err.Error(), redaction.Options{}) + return m, nil + } + } if strings.TrimSpace(m.userConfigPath) != "" { if store, err := config.ProviderKeyStoreAt(filepath.Dir(m.userConfigPath)); err == nil { _, _ = store.Delete(name) } - _, _ = config.ClearProviderKeyStored(m.userConfigPath, name) + _, _ = config.ClearProviderKeyStoredCaseVariants(m.userConfigPath, name) } else { _, _ = config.ForgetProviderKey(name) } diff --git a/internal/tui/provider_wizard_test.go b/internal/tui/provider_wizard_test.go index 1013bd917..534b3e669 100644 --- a/internal/tui/provider_wizard_test.go +++ b/internal/tui/provider_wizard_test.go @@ -719,6 +719,66 @@ func TestProviderWizardPersistsPastedKeyToUserConfig(t *testing.T) { } } +func TestProviderWizardRejectsCaseVariantBeforeCredentialCapture(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"activeProvider":"work","providers":[{"name":"work","apiKeyStored":true}]}`), 0o600); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "OLD"); err != nil { + t.Fatal(err) + } + + m := newModel(context.Background(), Options{ + UserConfigPath: configPath, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return &fakeProvider{}, nil + }, + }) + m = openProviderWizardForTest(t, m) + m.providerWizard.selectedProvider = providerWizardProviderIndex(t, m.providerWizard, "ollama-cloud") + m.providerWizard.profileName = "WORK" + updated, _ := m.Update(testKey(tea.KeyEnter)) + next := updated.(model) + updated, _ = next.Update(testPaste("NEW")) + next = updated.(model) + updated, _ = next.Update(testKey(tea.KeyEnter)) + next = updated.(model) + updated, _ = next.Update(testKey(tea.KeyEnter)) + next = updated.(model) + next = finishProviderWizardModelDiscoveryForTest(t, next) + updated, _ = next.Update(testKey(tea.KeyEnter)) + next = updated.(model) + updated, _ = next.Update(testKey(tea.KeyEnter)) + next = updated.(model) + + if next.providerWizard == nil { + t.Fatal("rejected wizard unexpectedly closed") + } + if !strings.Contains(next.providerWizard.err, `provider "WORK" already exists as "work"`) { + t.Fatalf("wizard error = %q, want case-variant collision", next.providerWizard.err) + } + after, readErr := os.ReadFile(configPath) + if readErr != nil { + t.Fatal(readErr) + } + if string(after) != string(before) { + t.Fatalf("rejected wizard rewrote config\nbefore: %s\nafter: %s", before, after) + } + if key, ok, getErr := store.Get("work"); getErr != nil || !ok || key != "OLD" { + t.Fatalf("existing credential = %q,%v,%v; want OLD,true,nil", key, ok, getErr) + } +} + func TestProviderWizardUsesAPIKeyEnvForCurrentSessionWithoutPersistingSecret(t *testing.T) { const secret = "ollama-env-secret" t.Setenv("OLLAMA_API_KEY", secret) @@ -1138,25 +1198,28 @@ func TestProviderWizardManageKeyRemove(t *testing.T) { if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(configPath, []byte(`{"providers":[{"name":"acme","apiKeyStored":true}]}`), 0o600); err != nil { + if err := os.WriteFile(configPath, []byte(`{"providers":[{"name":"work","apiKeyStored":true}]}`), 0o600); err != nil { t.Fatal(err) } store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) if err != nil { t.Fatal(err) } - if err := store.Set("acme", "sk-secret"); err != nil { + if err := store.Set("work", "sk-secret"); err != nil { t.Fatal(err) } m := newModel(context.Background(), Options{UserConfigPath: configPath}) - m.providerWizard = &providerWizardState{step: providerWizardStepManageKey, manageProviderName: "acme", manageKeyCursor: 2} + m.providerWizard = &providerWizardState{step: providerWizardStepManageKey, manageProviderName: "WORK", manageKeyCursor: 2} next, _ := m.applyManageKeyChoice() if next.providerWizard != nil { t.Fatal("remove should close the wizard") } - if _, ok, _ := store.Get("acme"); ok { - t.Fatal("remove should delete the key from the credential store") + if _, ok, _ := store.Get("work"); ok { + t.Fatal("remove should delete the normalized key from the credential store") + } + if cfg := readProviderWizardConfigFixture(t, configPath); cfg.Providers[0].APIKeyStored { + t.Fatal("case-variant removal left apiKeyStored set") } } From e709787e285a3f348e42c07360bd390242a23aed Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Wed, 12 Aug 2026 22:11:21 +0200 Subject: [PATCH 04/17] fix: address provider review feedback Co-authored-by: Pierre Bruno --- internal/cli/auth_test.go | 19 +++++++++- internal/config/credentials_test.go | 4 +- internal/tui/model.go | 4 ++ internal/tui/provider_manager.go | 29 ++++++++++----- internal/tui/provider_manager_test.go | 53 ++++++++++++++++++++++++++- internal/tui/provider_wizard.go | 23 +++++++++--- internal/tui/provider_wizard_test.go | 42 +++++++++++++++++++++ 7 files changed, 155 insertions(+), 19 deletions(-) diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index abddcd6ae..7291d7c2c 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -380,6 +380,14 @@ func TestRunAuthLogoutRejectsAmbiguousConfigBeforeCredentialDeletion(t *testing. if err := store.Set("work", "sk-shared"); err != nil { t.Fatal(err) } + oauthStore, err := oauth.NewStore(oauth.StoreOptions{}) + if err != nil { + t.Fatal(err) + } + oauthToken := oauth.Token{AccessToken: "oauth-access", RefreshToken: "oauth-refresh", Account: "work@example.com"} + if err := oauthStore.Save(oauth.ProviderKey("work"), oauthToken); err != nil { + t.Fatal(err) + } var stdout, stderr bytes.Buffer deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }} @@ -387,7 +395,16 @@ func TestRunAuthLogoutRejectsAmbiguousConfigBeforeCredentialDeletion(t *testing. t.Fatalf("logout exit = %d, want validation failure", code) } if key, ok, getErr := store.Get("work"); getErr != nil || !ok || key != "sk-shared" { - t.Fatalf("shared credential changed before rejection: %q,%v,%v", key, ok, getErr) + t.Fatalf("shared API credential changed before rejection: present=%v err=%v", ok, getErr) + } + storedOAuth, ok, loadErr := oauthStore.Load(oauth.ProviderKey("work")) + if loadErr != nil || !ok { + t.Fatalf("OAuth credential missing after rejection: ok=%v err=%v", ok, loadErr) + } + if storedOAuth.AccessToken != oauthToken.AccessToken || + storedOAuth.RefreshToken != oauthToken.RefreshToken || + storedOAuth.Account != oauthToken.Account { + t.Fatal("OAuth credential changed before ambiguous-config rejection") } after, readErr := os.ReadFile(configPath) if readErr != nil { diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go index 3cea72605..389428222 100644 --- a/internal/config/credentials_test.go +++ b/internal/config/credentials_test.go @@ -138,14 +138,14 @@ func TestMigratePlaintextProviderKeysValidatesBeforeStoreWrites(t *testing.T) { t.Fatalf("migrate = %d,%v; want validation error", n, err) } if n != 0 || len(store.keys) != 0 { - t.Fatalf("invalid config mutated credential store: migrated=%d keys=%v", n, store.keys) + t.Fatalf("invalid config mutated credential store: migrated=%d keyCount=%d", n, len(store.keys)) } after, readErr := os.ReadFile(path) if readErr != nil { t.Fatal(readErr) } if string(after) != string(before) { - t.Fatalf("invalid config was rewritten\nbefore: %s\nafter: %s", before, after) + t.Fatalf("invalid config was rewritten: beforeBytes=%d afterBytes=%d", len(before), len(after)) } } diff --git a/internal/tui/model.go b/internal/tui/model.go index 72b081258..2ea0d07eb 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -88,6 +88,8 @@ type model struct { probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error) + deleteProviderKey func(configPath, provider string) (bool, error) + clearProviderKeyStored func(configPath, provider string) (bool, error) registry *tools.Registry // lspManager is created once per session and reused across prompts so gopls (and // other language servers) stay warm — a fresh manager per run would cold-start @@ -969,6 +971,8 @@ func newModel(ctx context.Context, options Options) model { probeProviderHealth: options.ProbeProviderHealth, discoverProviderModels: options.DiscoverProviderModels, discoverOllamaContextWindow: options.DiscoverOllamaContextWindow, + deleteProviderKey: deleteProviderKey, + clearProviderKeyStored: config.ClearProviderKeyStoredCaseVariants, registry: registry, sessionStore: sessionStore, peerService: options.PeerService, diff --git a/internal/tui/provider_manager.go b/internal/tui/provider_manager.go index 51303ec8e..9bd80dc7f 100644 --- a/internal/tui/provider_manager.go +++ b/internal/tui/provider_manager.go @@ -363,8 +363,13 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { return m, nil } activeAfter = cfg.ActiveProvider - notes = []string{"Deleted " + name + "."} - cleanup = providerManagerCleanupCmd(m.userConfigPath, row.profile, !providerIdentitySurvives(cfg.Providers, name)) + deleteStoredKey := !providerIdentitySurvives(cfg.Providers, name) + if deleteStoredKey { + notes = []string{"Deleted " + name + ". Its stored API key will also be deleted."} + } else { + notes = []string{"Deleted " + name + ". Kept its stored API key because another provider uses the same credential identity."} + } + cleanup = providerManagerCleanupCmd(m.userConfigPath, row.profile, deleteStoredKey) } else { // Env-derived providers have no persisted profile or credential to // delete. Keep this path session-only. @@ -378,9 +383,9 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { // must not replace the resolved/filtered savedProviders wholesale. m.savedProviders = removeSavedProvider(m.savedProviders, name) - if config.SameProviderIdentity(m.providerName, name) { + if samePersistedProviderName(m.providerName, name) { notes = append(notes, "This session keeps running on it until you switch.") - } else if activeAfter != "" && !config.SameProviderIdentity(activeAfter, name) { + } else if activeAfter != "" && !samePersistedProviderName(activeAfter, name) { notes = append(notes, "Active provider: "+activeAfter+".") } @@ -415,6 +420,10 @@ func providerIdentitySurvives(providers []config.ProviderProfile, removedName st return false } +func samePersistedProviderName(left, right string) bool { + return strings.TrimSpace(left) == strings.TrimSpace(right) +} + // providerManagerCleanupMsg reports the off-thread half of a delete: the // stored-key removal outcome and the OAuth-login hint. type providerManagerCleanupMsg struct { @@ -600,10 +609,6 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { wizard.err = "no user config path — cannot save" return m, nil } - if err := config.PreflightUserConfig(m.userConfigPath); err != nil { - wizard.err = err.Error() - return m, nil - } oldName := strings.TrimSpace(wizard.editOriginal.Name) persisted, err := config.ProviderPersisted(m.userConfigPath, oldName) if err != nil { @@ -627,6 +632,10 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { Description: wizard.editDraft.Description, } if key := strings.TrimSpace(wizard.editDraft.APIKey); key != "" { + if err := config.PreflightUserConfig(m.userConfigPath); err != nil { + wizard.err = err.Error() + return m, nil + } captured := config.SecureProviderProfile(config.ProviderProfile{Name: oldName, APIKey: key}, m.userConfigPath) // On a store failure SecureProviderProfile keeps the inline key, which // EditProvider then persists (the startup migration re-captures later) — @@ -646,7 +655,7 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { // Keep the live session's identity in sync with a rename of the provider it // is running on: the exported ZERO_PROVIDER must resolve for spawned children. - if config.SameProviderIdentity(m.providerName, oldName) { + if samePersistedProviderName(m.providerName, oldName) { m.providerName = newName m.providerProfile.Name = newName config.SetActiveProviderEnv(newName) @@ -664,7 +673,7 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { // liveName is the session's provider AFTER any rename sync, so a single // comparison against the edited profile's final name suffices. func providerEditRestartNote(liveName string, editedName string) string { - if config.SameProviderIdentity(liveName, editedName) { + if samePersistedProviderName(liveName, editedName) { return " Press Enter on it to apply the changes to this session." } return "" diff --git a/internal/tui/provider_manager_test.go b/internal/tui/provider_manager_test.go index ae6fa78b1..b68e65ca6 100644 --- a/internal/tui/provider_manager_test.go +++ b/internal/tui/provider_manager_test.go @@ -683,7 +683,13 @@ func TestProviderManagerRemoveKeepsSharedCredentialForCaseVariantSurvivor(t *tes t.Fatalf("in-memory survivor = %+v, want work", next.savedProviders) } if key, ok, getErr := store.Get("work"); getErr != nil || !ok || key != "sk-shared" { - t.Fatalf("shared key = %q,%v,%v; want sk-shared,true,nil", key, ok, getErr) + t.Fatalf("shared key changed: present=%v err=%v", ok, getErr) + } + if next.providerName != "work" || next.providerProfile.Name != "work" { + t.Fatalf("removing WORK changed live work identity: name=%q profile=%q", next.providerName, next.providerProfile.Name) + } + if status := next.providerWizard.manageStatus; !strings.Contains(status, "Kept its stored API key") || !strings.Contains(status, "Active provider: work") { + t.Fatalf("delete status did not describe retained key and surviving active row: %q", status) } } @@ -742,3 +748,48 @@ func TestProviderManagerKeepsDistinctUnicodeLiveProviderOnOtherRowMutation(t *te } }) } + +func TestProviderManagerCaseVariantEditDoesNotChangeLiveSibling(t *testing.T) { + t.Setenv(config.ActiveProviderEnv, "work") + profile := config.ProviderProfile{ + Name: "WORK", + ProviderKind: config.ProviderKindOpenAICompatible, + BaseURL: "https://other.example/v1", + Model: "other-model", + } + path := filepath.Join(t.TempDir(), "config.json") + data, err := json.Marshal(config.FileConfig{ActiveProvider: "WORK", Providers: []config.ProviderProfile{profile}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + ProviderName: "work", + ProviderProfile: config.ProviderProfile{Name: "work"}, + SavedProviders: []config.ProviderProfile{profile}, + UserConfigPath: path, + }) + m, _ = m.openProviderManager() + m.providerWizard.beginProviderEdit(profile) + m.providerWizard.editDraft.Name = "OFFICE" + next, _ := m.saveManagerEdit() + + if next.providerName != "work" || next.providerProfile.Name != "work" { + t.Fatalf("editing WORK rewrote live work identity: name=%q profile=%q", next.providerName, next.providerProfile.Name) + } + if got := os.Getenv(config.ActiveProviderEnv); got != "work" { + t.Fatalf("%s = %q, want live work unchanged", config.ActiveProviderEnv, got) + } + if next.providerWizard == nil || next.providerWizard.err != "" { + t.Fatalf("case-variant sibling edit failed: %+v", next.providerWizard) + } + if len(next.savedProviders) != 1 || next.savedProviders[0].Name != "OFFICE" { + t.Fatalf("wrong in-memory edit target: %+v", next.savedProviders) + } + cfg := readManagerConfig(t, path) + if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "OFFICE" { + t.Fatalf("wrong persisted edit target: %+v", cfg.Providers) + } +} diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index 40388a883..c65026592 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -1338,13 +1338,15 @@ func (m model) applyManageKeyChoice() (model, tea.Cmd) { return m, nil } } + if _, err := m.deleteProviderKey(m.userConfigPath, name); err != nil { + wizard.err = "Stored key removal failed: " + redaction.ErrorMessage(err, redaction.Options{}) + return m, nil + } if strings.TrimSpace(m.userConfigPath) != "" { - if store, err := config.ProviderKeyStoreAt(filepath.Dir(m.userConfigPath)); err == nil { - _, _ = store.Delete(name) + if _, err := m.clearProviderKeyStored(m.userConfigPath, name); err != nil { + wizard.err = "Stored key marker cleanup failed: " + redaction.ErrorMessage(err, redaction.Options{}) + return m, nil } - _, _ = config.ClearProviderKeyStoredCaseVariants(m.userConfigPath, name) - } else { - _, _ = config.ForgetProviderKey(name) } m.providerWizard = nil m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Provider\nRemoved the stored key for " + name + ". Re-add it any time with /provider."}) @@ -1356,6 +1358,17 @@ func (m model) applyManageKeyChoice() (model, tea.Cmd) { } } +func deleteProviderKey(configPath, provider string) (bool, error) { + if strings.TrimSpace(configPath) == "" { + return config.ForgetProviderKey(provider) + } + store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) + if err != nil { + return false, err + } + return store.Delete(provider) +} + func providerWizardRuntimeProfile(profile config.ProviderProfile) config.ProviderProfile { runtimeProfile := profile if strings.TrimSpace(runtimeProfile.APIKey) == "" && strings.TrimSpace(runtimeProfile.APIKeyEnv) != "" { diff --git a/internal/tui/provider_wizard_test.go b/internal/tui/provider_wizard_test.go index 534b3e669..ed925d11d 100644 --- a/internal/tui/provider_wizard_test.go +++ b/internal/tui/provider_wizard_test.go @@ -1223,6 +1223,48 @@ func TestProviderWizardManageKeyRemove(t *testing.T) { } } +func TestProviderWizardManageKeyRemoveReportsCleanupFailures(t *testing.T) { + newRemovalModel := func(t *testing.T) model { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"providers":[{"name":"work","apiKeyStored":true}]}`), 0o600); err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{UserConfigPath: path}) + m.providerWizard = &providerWizardState{step: providerWizardStepManageKey, manageProviderName: "work", manageKeyCursor: 2} + return m + } + + t.Run("stored key deletion", func(t *testing.T) { + m := newRemovalModel(t) + m.deleteProviderKey = func(string, string) (bool, error) { + return false, errors.New("injected delete failure") + } + next, _ := m.applyManageKeyChoice() + if next.providerWizard == nil || !strings.Contains(next.providerWizard.err, "Stored key removal failed") { + t.Fatalf("wizard did not remain open with deletion error: %+v", next.providerWizard) + } + if cfg := readProviderWizardConfigFixture(t, next.userConfigPath); !cfg.Providers[0].APIKeyStored { + t.Fatal("deletion failure cleared the persisted marker") + } + }) + + t.Run("persisted marker cleanup", func(t *testing.T) { + m := newRemovalModel(t) + m.deleteProviderKey = func(string, string) (bool, error) { return true, nil } + m.clearProviderKeyStored = func(string, string) (bool, error) { + return false, errors.New("injected marker failure") + } + next, _ := m.applyManageKeyChoice() + if next.providerWizard == nil || !strings.Contains(next.providerWizard.err, "Stored key marker cleanup failed") { + t.Fatalf("wizard did not remain open with marker error: %+v", next.providerWizard) + } + if cfg := readProviderWizardConfigFixture(t, next.userConfigPath); !cfg.Providers[0].APIKeyStored { + t.Fatal("injected marker failure unexpectedly changed config") + } + }) +} + func TestProviderWizardManageKeyReplaceAndKeep(t *testing.T) { m := newModel(context.Background(), Options{UserConfigPath: filepath.Join(t.TempDir(), "config.json")}) From 82243d3c366661ad7cd1fba47b21e1e30613a40c Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Thu, 13 Aug 2026 15:57:08 +0200 Subject: [PATCH 05/17] fix: serialize provider credential updates Co-authored-by: Pierre Bruno --- internal/cli/auth.go | 18 ++- internal/cli/auth_test.go | 37 ++++++ internal/cli/provider_onboarding.go | 11 +- internal/cli/provider_setup.go | 11 +- internal/cli/setup.go | 10 +- internal/config/credentials.go | 11 ++ internal/config/provider_commit.go | 156 ++++++++++++++++++++++++ internal/config/provider_commit_test.go | 93 ++++++++++++++ internal/config/writer.go | 36 +++--- internal/tui/command_center.go | 4 +- internal/tui/model.go | 2 +- internal/tui/provider_identity_test.go | 25 ++++ internal/tui/provider_manager.go | 11 +- internal/tui/provider_wizard.go | 18 ++- 14 files changed, 375 insertions(+), 68 deletions(-) create mode 100644 internal/config/provider_commit.go create mode 100644 internal/config/provider_commit_test.go create mode 100644 internal/tui/provider_identity_test.go diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 46302e7c0..95696d69e 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -131,6 +131,9 @@ func saveOpenRouterProviderKey(deps appDeps, key string) (string, error) { if err != nil { return "", err } + if err := config.PreflightUserConfig(configPath); err != nil { + return "", err + } ensured, err := config.EnsureCatalogProvider(configPath, "openrouter") if err != nil { return "", err @@ -139,13 +142,22 @@ func saveOpenRouterProviderKey(deps appDeps, key string) (string, error) { if err != nil { return "", err } + previous, previousPresent, err := store.Get(ensured.Name) + if err != nil { + return "", err + } if err := store.Set(ensured.Name, key); err != nil { return "", err } if err := config.MarkProviderAPIKeyStored(configPath, ensured.Name); err != nil { - // Best-effort rollback: don't leave the key orphaned in the credential - // store while config.json still says it isn't there. - _, _ = store.Delete(ensured.Name) + current, present, getErr := store.Get(ensured.Name) + if getErr == nil && present && current == key { + if previousPresent { + _ = store.Set(ensured.Name, previous) + } else { + _, _ = store.Delete(ensured.Name) + } + } return "", err } active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name)) diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 7291d7c2c..a5faa4c3f 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -191,6 +191,43 @@ func TestRunAuthOpenRouterSavesMintedKey(t *testing.T) { } } +func TestSaveOpenRouterProviderKeyRejectsAmbiguousConfigWithoutReplacingKey(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + configPath := filepath.Join(t.TempDir(), "config.json") + before := []byte(`{"activeProvider":"openrouter","providers":[{"name":"openrouter","apiKeyStored":true},{"name":"OPENROUTER","apiKeyStored":true}]}`) + if err := os.WriteFile(configPath, before, 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) + if err != nil { + t.Fatal(err) + } + if err := store.Set("openrouter", "old-key"); err != nil { + t.Fatal(err) + } + + _, err = saveOpenRouterProviderKey(appDeps{ + userConfigPath: func() (string, error) { return configPath, nil }, + }, "new-key") + if err == nil { + t.Fatal("expected ambiguous persisted names to be rejected") + } + after, readErr := os.ReadFile(configPath) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(after, before) { + t.Fatalf("config changed: beforeBytes=%d afterBytes=%d", len(before), len(after)) + } + key, ok, getErr := store.Get("openrouter") + if getErr != nil { + t.Fatal(getErr) + } + if !ok || key != "old-key" { + t.Fatalf("existing credential present=%v preserved=%v", ok, key == "old-key") + } +} + func TestRunAuthHelp(t *testing.T) { var stdout, stderr bytes.Buffer if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess { diff --git a/internal/cli/provider_onboarding.go b/internal/cli/provider_onboarding.go index 320a1d2e4..213411638 100644 --- a/internal/cli/provider_onboarding.go +++ b/internal/cli/provider_onboarding.go @@ -422,7 +422,7 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps // store setup/rename write to — not the default-path store, so a // non-default config path cannot leave the encrypted key behind. keyRemoved, keyErr := false, error(nil) - if !providerIdentitySurvives(cfg.Providers, name) { + if !config.ProviderCredentialSurvives(cfg.Providers, name) { keyRemoved, keyErr = removeStoredProviderKeyAt(configPath, name) } if options.json { @@ -476,15 +476,6 @@ func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) return store.Delete(provider) } -func providerIdentitySurvives(providers []config.ProviderProfile, removedName string) bool { - for _, provider := range providers { - if config.SameProviderIdentity(provider.Name, removedName) { - return true - } - } - return false -} - // runProvidersRename renames a saved provider profile, migrating its stored // API key and the activeProvider pointer along with it (config.RenameProvider). func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { diff --git a/internal/cli/provider_setup.go b/internal/cli/provider_setup.go index dc95bc1d9..03d72ffbf 100644 --- a/internal/cli/provider_setup.go +++ b/internal/cli/provider_setup.go @@ -53,15 +53,14 @@ func runProvidersAdd(args []string, stdout io.Writer, stderr io.Writer, deps app if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } - if err := config.PreflightProviderWrite(configPath, profile.Name); err != nil { - return writeAppError(stderr, err.Error(), exitCrash) - } - // Persist with the key moved into the encrypted credential store (capture flip); - // the local profile keeps the key for the verification build below. - cfg, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), options.setActive) + result, err := config.CommitProviderProfile(configPath, config.ProviderCommit{ + Profile: profile, + SetActive: options.setActive, + }) if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } + cfg := result.Config if options.json { if err := writePrettyJSON(stdout, map[string]any{ diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 4ee8c89c3..106d694cb 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -264,12 +264,10 @@ func saveSetupProvider(deps appDeps, selection tui.SetupSelection, options setup if err != nil { return tui.SetupResult{}, err } - if err := config.PreflightProviderWrite(configPath, profile.Name); err != nil { - return tui.SetupResult{}, err - } - // Persist with the key moved into the encrypted credential store (capture flip); - // the returned profile keeps the key for this run's immediate use. - if _, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), true); err != nil { + if _, err := config.CommitProviderProfile(configPath, config.ProviderCommit{ + Profile: profile, + SetActive: true, + }); err != nil { return tui.SetupResult{}, err } return tui.SetupResult{ConfigPath: configPath, Provider: profile}, nil diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 23f48e1f3..ad5de3e34 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -43,6 +43,17 @@ type APIKeySetter interface { Set(provider, key string) error } +// ProviderCredentialSurvives reports whether a remaining persisted row still +// references the removed provider's normalized credential. +func ProviderCredentialSurvives(providers []ProviderProfile, removedName string) bool { + for _, provider := range providers { + if provider.APIKeyStored && SameProviderIdentity(provider.Name, removedName) { + return true + } + } + return false +} + // SecureProviderProfile moves an inline APIKey on the profile into the credential // store co-located with configPath, returning a profile with APIKeyStored set and // APIKey cleared so the secret is never written to config.json. On any store error diff --git a/internal/config/provider_commit.go b/internal/config/provider_commit.go new file mode 100644 index 000000000..6a50efae5 --- /dev/null +++ b/internal/config/provider_commit.go @@ -0,0 +1,156 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/Gitlawb/zero/internal/credstore" + "github.com/Gitlawb/zero/internal/lockutil" +) + +var providerWriteLockTimeout = 5 * time.Second +var providerWriteLockSeq atomic.Uint64 + +type ProviderCommit struct { + Profile ProviderProfile + SetActive bool + KeepStoredKey bool +} + +type ProviderCommitResult struct { + Config FileConfig + Persisted ProviderProfile +} + +// CommitProviderProfile serializes validation, credential capture, and config +// publication so a rejected concurrent case-variant write cannot replace the +// winning provider's credential. +func CommitProviderProfile(path string, commit ProviderCommit) (result ProviderCommitResult, err error) { + path = strings.TrimSpace(path) + if path == "" { + return ProviderCommitResult{}, fmt.Errorf("config path is required") + } + release, err := lockProviderWrite(path) + if err != nil { + return ProviderCommitResult{}, err + } + defer func() { + if releaseErr := release(); releaseErr != nil { + result = ProviderCommitResult{} + err = errors.Join(err, releaseErr) + } + }() + + cfg := FileConfig{} + if data, readErr := os.ReadFile(path); readErr == nil { + if err := json.Unmarshal(data, &cfg); err != nil { + return ProviderCommitResult{}, fmt.Errorf("invalid config JSON %s: %w", path, err) + } + } else if !os.IsNotExist(readErr) { + return ProviderCommitResult{}, fmt.Errorf("read config %s: %w", path, readErr) + } + if err := ValidatePersistedProviderNames(cfg); err != nil { + return ProviderCommitResult{}, err + } + + persisted := commit.Profile + var store *credstore.Store + var previous string + var previousPresent bool + var written string + captured := !commit.KeepStoredKey && strings.TrimSpace(persisted.APIKey) != "" + if captured { + store, err = ProviderKeyStoreAt(filepath.Dir(path)) + if err != nil { + return ProviderCommitResult{}, err + } + previous, previousPresent, err = store.Get(persisted.Name) + if err != nil { + return ProviderCommitResult{}, err + } + written = persisted.APIKey + if err := store.Set(persisted.Name, written); err != nil { + return ProviderCommitResult{}, fmt.Errorf("store API key for %q: %w", persisted.Name, err) + } + persisted.APIKey = "" + persisted.APIKeyStored = true + } + + rollback := func() { + if !captured { + return + } + current, present, getErr := store.Get(persisted.Name) + if getErr != nil || !present || current != written { + return + } + if previousPresent { + _ = store.Set(persisted.Name, previous) + } else { + _, _ = store.Delete(persisted.Name) + } + } + if err := upsertProviderConfig(&cfg, persisted, commit.SetActive); err != nil { + rollback() + return ProviderCommitResult{}, err + } + if err := writeConfigFile(path, cfg); err != nil { + rollback() + return ProviderCommitResult{}, err + } + return ProviderCommitResult{Config: cfg, Persisted: persisted}, nil +} + +func lockProviderWrite(configPath string) (func() error, error) { + lockPath := filepath.Join(filepath.Dir(configPath), ".zero-provider-write.lock") + if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil { + return nil, fmt.Errorf("acquire provider config/key transaction lock: %w", err) + } + token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), providerWriteLockSeq.Add(1)) + deadline := time.Now().Add(providerWriteLockTimeout) + for { + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + if _, writeErr := file.WriteString(token); writeErr != nil { + _ = file.Close() + _ = lockutil.RemoveLockFile(lockPath) + return nil, fmt.Errorf("write provider config/key transaction lock: %w", writeErr) + } + if closeErr := file.Close(); closeErr != nil { + _ = lockutil.RemoveLockFile(lockPath) + return nil, fmt.Errorf("close provider config/key transaction lock: %w", closeErr) + } + released := false + return func() error { + if released { + return nil + } + released = true + data, readErr := os.ReadFile(lockPath) + if readErr != nil { + return fmt.Errorf("release provider config/key transaction lock: %w", readErr) + } + if string(data) != token { + return fmt.Errorf("release provider config/key transaction lock: ownership changed") + } + if err := lockutil.RemoveLockFile(lockPath); err != nil { + return fmt.Errorf("release provider config/key transaction lock: %w", err) + } + return nil + }, nil + } + if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) { + return nil, fmt.Errorf("acquire provider config/key transaction lock: %w", err) + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("provider config/key transaction is busy; retry the operation") + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/internal/config/provider_commit_test.go b/internal/config/provider_commit_test.go new file mode 100644 index 000000000..5bff86a90 --- /dev/null +++ b/internal/config/provider_commit_test.go @@ -0,0 +1,93 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestCommitProviderProfileSerializesCaseVariantCredentialCapture(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + path := filepath.Join(t.TempDir(), "config.json") + + type outcome struct { + name string + key string + err error + } + start := make(chan struct{}) + outcomes := make(chan outcome, 2) + var ready sync.WaitGroup + ready.Add(2) + for _, candidate := range []outcome{{name: "work", key: "key-one"}, {name: "WORK", key: "key-two"}} { + candidate := candidate + go func() { + ready.Done() + <-start + _, err := CommitProviderProfile(path, ProviderCommit{Profile: ProviderProfile{Name: candidate.name, APIKey: candidate.key}}) + candidate.err = err + outcomes <- candidate + }() + } + ready.Wait() + close(start) + first, second := <-outcomes, <-outcomes + if (first.err == nil) == (second.err == nil) { + t.Fatalf("success count = %d, want 1", boolInt(first.err == nil)+boolInt(second.err == nil)) + } + winner := first + if winner.err != nil { + winner = second + } + loser := second + if loser.err == nil { + loser = first + } + if !strings.Contains(loser.err.Error(), "already exists as") { + t.Fatalf("loser error = %v", loser.err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var cfg FileConfig + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + if len(cfg.Providers) != 1 || cfg.Providers[0].Name != winner.name || !cfg.Providers[0].APIKeyStored { + t.Fatalf("persisted providers = %+v, want stored winner %q", cfg.Providers, winner.name) + } + store, err := ProviderKeyStoreAt(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + key, ok, err := store.Get(winner.name) + if err != nil { + t.Fatal(err) + } + if !ok || key != winner.key { + t.Fatalf("winner credential present=%v matches=%v", ok, key == winner.key) + } +} + +func TestProviderCredentialSurvivesRequiresStoredMarker(t *testing.T) { + providers := []ProviderProfile{{Name: "WORK"}} + if ProviderCredentialSurvives(providers, "work") { + t.Fatal("markerless case-variant row must not retain the credential") + } + providers[0].APIKeyStored = true + if !ProviderCredentialSurvives(providers, "work") { + t.Fatal("stored-key case-variant row must retain the credential") + } +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/internal/config/writer.go b/internal/config/writer.go index e71e0f217..f59d3aeba 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -149,11 +149,6 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC if path == "" { return FileConfig{}, fmt.Errorf("config path is required") } - profile.Name = strings.TrimSpace(profile.Name) - if profile.Name == "" { - return FileConfig{}, fmt.Errorf("provider name is required") - } - cfg := FileConfig{} if data, err := os.ReadFile(path); err == nil { if err := json.Unmarshal(data, &cfg); err != nil { @@ -165,19 +160,26 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC if err := ValidatePersistedProviderNames(cfg); err != nil { return FileConfig{}, err } + if err := upsertProviderConfig(&cfg, profile, setActive); err != nil { + return FileConfig{}, err + } + if err := writeConfigFile(path, cfg); err != nil { + return FileConfig{}, err + } + return cfg, nil +} + +func upsertProviderConfig(cfg *FileConfig, profile ProviderProfile, setActive bool) error { + profile.Name = strings.TrimSpace(profile.Name) + if profile.Name == "" { + return fmt.Errorf("provider name is required") + } for _, existing := range cfg.Providers { if sameProviderIdentity(existing.Name, profile.Name) && strings.TrimSpace(existing.Name) != profile.Name { - return FileConfig{}, fmt.Errorf("provider %q already exists as %q; provider names must be unique case-insensitively", profile.Name, existing.Name) + return fmt.Errorf("provider %q already exists as %q; provider names must be unique case-insensitively", profile.Name, existing.Name) } } - - mergeProvider(&cfg, profile) - // mergeProfile deliberately ignores APIKeyStored — during resolve-time - // layering a project config must not be able to claim the user's stored - // keys. This user-config WRITE path re-applies the marker: capturing a key - // via SecureProviderProfile onto a previously env/no-key profile must - // persist apiKeyStored, or the secret sits in the credential store while - // every ApplyStoredAPIKey gate skips it (PR #560 review). + mergeProvider(cfg, profile) if profile.APIKeyStored { for index := range cfg.Providers { if cfg.Providers[index].Name == profile.Name { @@ -189,11 +191,7 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC if setActive || strings.TrimSpace(cfg.ActiveProvider) == "" { cfg.ActiveProvider = profile.Name } - - if err := writeConfigFile(path, cfg); err != nil { - return FileConfig{}, err - } - return cfg, nil + return nil } // EnsuredProvider reports the outcome of EnsureCatalogProvider: the profile name diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index f922ee5bd..855628918 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -677,11 +677,11 @@ func oauthLoginName(profile config.ProviderProfile) (string, bool) { func (m model) savedProviderByName(name string) (config.ProviderProfile, bool) { name = strings.TrimSpace(name) for _, profile := range m.savedProviders { - if strings.EqualFold(strings.TrimSpace(profile.Name), name) { + if strings.TrimSpace(profile.Name) == name { return profile, true } } - if strings.EqualFold(strings.TrimSpace(m.providerProfile.Name), name) { + if strings.TrimSpace(m.providerProfile.Name) == name { return m.providerProfile, true } return config.ProviderProfile{}, false diff --git a/internal/tui/model.go b/internal/tui/model.go index 2ea0d07eb..df4819f28 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -4414,7 +4414,7 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) { text := "" owner := strings.TrimSpace(item.OwnerProvider) _, ownerIsSavedProvider := m.savedProviderByName(owner) - if owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) && ownerIsSavedProvider { + if owner != "" && owner != strings.TrimSpace(m.providerName) && ownerIsSavedProvider { // A model from another saved provider: switch provider + model together. m, text, _, cmd = m.switchProviderModel(owner, item.Value) } else { diff --git a/internal/tui/provider_identity_test.go b/internal/tui/provider_identity_test.go new file mode 100644 index 000000000..70e434c29 --- /dev/null +++ b/internal/tui/provider_identity_test.go @@ -0,0 +1,25 @@ +package tui + +import ( + "testing" + + "github.com/Gitlawb/zero/internal/config" +) + +func TestSavedProviderByNameDistinguishesUnicodeCredentialIdentities(t *testing.T) { + m := model{ + providerProfile: config.ProviderProfile{Name: "s", Model: "ascii-model"}, + savedProviders: []config.ProviderProfile{ + {Name: "s", Model: "ascii-model"}, + {Name: "ſ", Model: "long-s-model"}, + }, + } + + profile, ok := m.savedProviderByName("ſ") + if !ok { + t.Fatal("long-s provider not found") + } + if profile.Name != "ſ" || profile.Model != "long-s-model" { + t.Fatalf("selected provider = %q/%q, want long-s provider", profile.Name, profile.Model) + } +} diff --git a/internal/tui/provider_manager.go b/internal/tui/provider_manager.go index 9bd80dc7f..280ea6d1f 100644 --- a/internal/tui/provider_manager.go +++ b/internal/tui/provider_manager.go @@ -363,7 +363,7 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { return m, nil } activeAfter = cfg.ActiveProvider - deleteStoredKey := !providerIdentitySurvives(cfg.Providers, name) + deleteStoredKey := !config.ProviderCredentialSurvives(cfg.Providers, name) if deleteStoredKey { notes = []string{"Deleted " + name + ". Its stored API key will also be deleted."} } else { @@ -411,15 +411,6 @@ func removeSavedProvider(saved []config.ProviderProfile, name string) []config.P return kept } -func providerIdentitySurvives(providers []config.ProviderProfile, removedName string) bool { - for _, provider := range providers { - if config.SameProviderIdentity(provider.Name, removedName) { - return true - } - } - return false -} - func samePersistedProviderName(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) } diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index c65026592..bcc882d7a 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -1261,21 +1261,17 @@ func (m model) applyProviderWizard() (model, tea.Cmd) { nextProvider = built } if strings.TrimSpace(m.userConfigPath) != "" { - if err := config.PreflightProviderWrite(m.userConfigPath, profile.Name); err != nil { - wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{profile.APIKey, runtimeProfile.APIKey}}) - return m, nil - } - // Capture flip: move the freshly entered key into the encrypted credential - // store before persisting, so config.json never holds the cleartext. The - // provider was already built above from runtimeProfile, which has the key. secret := profile.APIKey - if !preserveExistingCredentialReference { - profile = config.SecureProviderProfile(profile, m.userConfigPath) - } - if _, err := config.UpsertProvider(m.userConfigPath, profile, true); err != nil { + result, err := config.CommitProviderProfile(m.userConfigPath, config.ProviderCommit{ + Profile: profile, + SetActive: true, + KeepStoredKey: preserveExistingCredentialReference, + }) + if err != nil { wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{secret, profile.APIKey}}) return m, nil // nothing committed to live state yet } + profile = result.Persisted } // Both succeeded — commit the live provider, profile, model, and the child From 88cc2fbdb4de38c20e1a4831a8255579583cf35b Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 14 Aug 2026 16:03:16 +0000 Subject: [PATCH 06/17] fix(config): keep provider transaction out of identity slice Review on #892 asked for the config/key transaction to stay in #894 so this PR keeps to the provider identity boundary it declares. Revert the CommitProviderProfile/lockProviderWrite implementation and restore the PreflightProviderWrite + UpsertProvider callers in the add, setup, onboarding, wizard, and manager paths. #894 owns the single authoritative transaction over the full writer inventory. Keep the Unicode credential-identity fix, which is identity scope: match saved providers with credstore.NormalizeProvider instead of strings.EqualFold. EqualFold folds "s" and long-s "\u017f" together while the credential store keeps separate entries, so a lookup could return a different provider's profile and reach its secret. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Pierre Bruno --- internal/cli/auth.go | 18 +-- internal/cli/auth_test.go | 37 ------ internal/cli/provider_onboarding.go | 11 +- internal/cli/provider_setup.go | 11 +- internal/cli/setup.go | 10 +- internal/config/credentials.go | 11 -- internal/config/provider_commit.go | 156 ------------------------ internal/config/provider_commit_test.go | 93 -------------- internal/config/writer.go | 36 +++--- internal/tui/command_center.go | 11 +- internal/tui/model.go | 3 +- internal/tui/provider_manager.go | 11 +- internal/tui/provider_wizard.go | 18 +-- 13 files changed, 75 insertions(+), 351 deletions(-) delete mode 100644 internal/config/provider_commit.go delete mode 100644 internal/config/provider_commit_test.go diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 95696d69e..46302e7c0 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -131,9 +131,6 @@ func saveOpenRouterProviderKey(deps appDeps, key string) (string, error) { if err != nil { return "", err } - if err := config.PreflightUserConfig(configPath); err != nil { - return "", err - } ensured, err := config.EnsureCatalogProvider(configPath, "openrouter") if err != nil { return "", err @@ -142,22 +139,13 @@ func saveOpenRouterProviderKey(deps appDeps, key string) (string, error) { if err != nil { return "", err } - previous, previousPresent, err := store.Get(ensured.Name) - if err != nil { - return "", err - } if err := store.Set(ensured.Name, key); err != nil { return "", err } if err := config.MarkProviderAPIKeyStored(configPath, ensured.Name); err != nil { - current, present, getErr := store.Get(ensured.Name) - if getErr == nil && present && current == key { - if previousPresent { - _ = store.Set(ensured.Name, previous) - } else { - _, _ = store.Delete(ensured.Name) - } - } + // Best-effort rollback: don't leave the key orphaned in the credential + // store while config.json still says it isn't there. + _, _ = store.Delete(ensured.Name) return "", err } active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name)) diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index a5faa4c3f..7291d7c2c 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -191,43 +191,6 @@ func TestRunAuthOpenRouterSavesMintedKey(t *testing.T) { } } -func TestSaveOpenRouterProviderKeyRejectsAmbiguousConfigWithoutReplacingKey(t *testing.T) { - t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") - configPath := filepath.Join(t.TempDir(), "config.json") - before := []byte(`{"activeProvider":"openrouter","providers":[{"name":"openrouter","apiKeyStored":true},{"name":"OPENROUTER","apiKeyStored":true}]}`) - if err := os.WriteFile(configPath, before, 0o600); err != nil { - t.Fatal(err) - } - store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) - if err != nil { - t.Fatal(err) - } - if err := store.Set("openrouter", "old-key"); err != nil { - t.Fatal(err) - } - - _, err = saveOpenRouterProviderKey(appDeps{ - userConfigPath: func() (string, error) { return configPath, nil }, - }, "new-key") - if err == nil { - t.Fatal("expected ambiguous persisted names to be rejected") - } - after, readErr := os.ReadFile(configPath) - if readErr != nil { - t.Fatal(readErr) - } - if !bytes.Equal(after, before) { - t.Fatalf("config changed: beforeBytes=%d afterBytes=%d", len(before), len(after)) - } - key, ok, getErr := store.Get("openrouter") - if getErr != nil { - t.Fatal(getErr) - } - if !ok || key != "old-key" { - t.Fatalf("existing credential present=%v preserved=%v", ok, key == "old-key") - } -} - func TestRunAuthHelp(t *testing.T) { var stdout, stderr bytes.Buffer if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess { diff --git a/internal/cli/provider_onboarding.go b/internal/cli/provider_onboarding.go index 213411638..320a1d2e4 100644 --- a/internal/cli/provider_onboarding.go +++ b/internal/cli/provider_onboarding.go @@ -422,7 +422,7 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps // store setup/rename write to — not the default-path store, so a // non-default config path cannot leave the encrypted key behind. keyRemoved, keyErr := false, error(nil) - if !config.ProviderCredentialSurvives(cfg.Providers, name) { + if !providerIdentitySurvives(cfg.Providers, name) { keyRemoved, keyErr = removeStoredProviderKeyAt(configPath, name) } if options.json { @@ -476,6 +476,15 @@ func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) return store.Delete(provider) } +func providerIdentitySurvives(providers []config.ProviderProfile, removedName string) bool { + for _, provider := range providers { + if config.SameProviderIdentity(provider.Name, removedName) { + return true + } + } + return false +} + // runProvidersRename renames a saved provider profile, migrating its stored // API key and the activeProvider pointer along with it (config.RenameProvider). func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { diff --git a/internal/cli/provider_setup.go b/internal/cli/provider_setup.go index 03d72ffbf..dc95bc1d9 100644 --- a/internal/cli/provider_setup.go +++ b/internal/cli/provider_setup.go @@ -53,14 +53,15 @@ func runProvidersAdd(args []string, stdout io.Writer, stderr io.Writer, deps app if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } - result, err := config.CommitProviderProfile(configPath, config.ProviderCommit{ - Profile: profile, - SetActive: options.setActive, - }) + if err := config.PreflightProviderWrite(configPath, profile.Name); err != nil { + return writeAppError(stderr, err.Error(), exitCrash) + } + // Persist with the key moved into the encrypted credential store (capture flip); + // the local profile keeps the key for the verification build below. + cfg, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), options.setActive) if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } - cfg := result.Config if options.json { if err := writePrettyJSON(stdout, map[string]any{ diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 106d694cb..4ee8c89c3 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -264,10 +264,12 @@ func saveSetupProvider(deps appDeps, selection tui.SetupSelection, options setup if err != nil { return tui.SetupResult{}, err } - if _, err := config.CommitProviderProfile(configPath, config.ProviderCommit{ - Profile: profile, - SetActive: true, - }); err != nil { + if err := config.PreflightProviderWrite(configPath, profile.Name); err != nil { + return tui.SetupResult{}, err + } + // Persist with the key moved into the encrypted credential store (capture flip); + // the returned profile keeps the key for this run's immediate use. + if _, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), true); err != nil { return tui.SetupResult{}, err } return tui.SetupResult{ConfigPath: configPath, Provider: profile}, nil diff --git a/internal/config/credentials.go b/internal/config/credentials.go index ad5de3e34..23f48e1f3 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -43,17 +43,6 @@ type APIKeySetter interface { Set(provider, key string) error } -// ProviderCredentialSurvives reports whether a remaining persisted row still -// references the removed provider's normalized credential. -func ProviderCredentialSurvives(providers []ProviderProfile, removedName string) bool { - for _, provider := range providers { - if provider.APIKeyStored && SameProviderIdentity(provider.Name, removedName) { - return true - } - } - return false -} - // SecureProviderProfile moves an inline APIKey on the profile into the credential // store co-located with configPath, returning a profile with APIKeyStored set and // APIKey cleared so the secret is never written to config.json. On any store error diff --git a/internal/config/provider_commit.go b/internal/config/provider_commit.go deleted file mode 100644 index 6a50efae5..000000000 --- a/internal/config/provider_commit.go +++ /dev/null @@ -1,156 +0,0 @@ -package config - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync/atomic" - "time" - - "github.com/Gitlawb/zero/internal/credstore" - "github.com/Gitlawb/zero/internal/lockutil" -) - -var providerWriteLockTimeout = 5 * time.Second -var providerWriteLockSeq atomic.Uint64 - -type ProviderCommit struct { - Profile ProviderProfile - SetActive bool - KeepStoredKey bool -} - -type ProviderCommitResult struct { - Config FileConfig - Persisted ProviderProfile -} - -// CommitProviderProfile serializes validation, credential capture, and config -// publication so a rejected concurrent case-variant write cannot replace the -// winning provider's credential. -func CommitProviderProfile(path string, commit ProviderCommit) (result ProviderCommitResult, err error) { - path = strings.TrimSpace(path) - if path == "" { - return ProviderCommitResult{}, fmt.Errorf("config path is required") - } - release, err := lockProviderWrite(path) - if err != nil { - return ProviderCommitResult{}, err - } - defer func() { - if releaseErr := release(); releaseErr != nil { - result = ProviderCommitResult{} - err = errors.Join(err, releaseErr) - } - }() - - cfg := FileConfig{} - if data, readErr := os.ReadFile(path); readErr == nil { - if err := json.Unmarshal(data, &cfg); err != nil { - return ProviderCommitResult{}, fmt.Errorf("invalid config JSON %s: %w", path, err) - } - } else if !os.IsNotExist(readErr) { - return ProviderCommitResult{}, fmt.Errorf("read config %s: %w", path, readErr) - } - if err := ValidatePersistedProviderNames(cfg); err != nil { - return ProviderCommitResult{}, err - } - - persisted := commit.Profile - var store *credstore.Store - var previous string - var previousPresent bool - var written string - captured := !commit.KeepStoredKey && strings.TrimSpace(persisted.APIKey) != "" - if captured { - store, err = ProviderKeyStoreAt(filepath.Dir(path)) - if err != nil { - return ProviderCommitResult{}, err - } - previous, previousPresent, err = store.Get(persisted.Name) - if err != nil { - return ProviderCommitResult{}, err - } - written = persisted.APIKey - if err := store.Set(persisted.Name, written); err != nil { - return ProviderCommitResult{}, fmt.Errorf("store API key for %q: %w", persisted.Name, err) - } - persisted.APIKey = "" - persisted.APIKeyStored = true - } - - rollback := func() { - if !captured { - return - } - current, present, getErr := store.Get(persisted.Name) - if getErr != nil || !present || current != written { - return - } - if previousPresent { - _ = store.Set(persisted.Name, previous) - } else { - _, _ = store.Delete(persisted.Name) - } - } - if err := upsertProviderConfig(&cfg, persisted, commit.SetActive); err != nil { - rollback() - return ProviderCommitResult{}, err - } - if err := writeConfigFile(path, cfg); err != nil { - rollback() - return ProviderCommitResult{}, err - } - return ProviderCommitResult{Config: cfg, Persisted: persisted}, nil -} - -func lockProviderWrite(configPath string) (func() error, error) { - lockPath := filepath.Join(filepath.Dir(configPath), ".zero-provider-write.lock") - if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil { - return nil, fmt.Errorf("acquire provider config/key transaction lock: %w", err) - } - token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), providerWriteLockSeq.Add(1)) - deadline := time.Now().Add(providerWriteLockTimeout) - for { - file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if err == nil { - if _, writeErr := file.WriteString(token); writeErr != nil { - _ = file.Close() - _ = lockutil.RemoveLockFile(lockPath) - return nil, fmt.Errorf("write provider config/key transaction lock: %w", writeErr) - } - if closeErr := file.Close(); closeErr != nil { - _ = lockutil.RemoveLockFile(lockPath) - return nil, fmt.Errorf("close provider config/key transaction lock: %w", closeErr) - } - released := false - return func() error { - if released { - return nil - } - released = true - data, readErr := os.ReadFile(lockPath) - if readErr != nil { - return fmt.Errorf("release provider config/key transaction lock: %w", readErr) - } - if string(data) != token { - return fmt.Errorf("release provider config/key transaction lock: ownership changed") - } - if err := lockutil.RemoveLockFile(lockPath); err != nil { - return fmt.Errorf("release provider config/key transaction lock: %w", err) - } - return nil - }, nil - } - if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) { - return nil, fmt.Errorf("acquire provider config/key transaction lock: %w", err) - } - if time.Now().After(deadline) { - return nil, fmt.Errorf("provider config/key transaction is busy; retry the operation") - } - time.Sleep(10 * time.Millisecond) - } -} diff --git a/internal/config/provider_commit_test.go b/internal/config/provider_commit_test.go deleted file mode 100644 index 5bff86a90..000000000 --- a/internal/config/provider_commit_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package config - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "sync" - "testing" -) - -func TestCommitProviderProfileSerializesCaseVariantCredentialCapture(t *testing.T) { - t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") - path := filepath.Join(t.TempDir(), "config.json") - - type outcome struct { - name string - key string - err error - } - start := make(chan struct{}) - outcomes := make(chan outcome, 2) - var ready sync.WaitGroup - ready.Add(2) - for _, candidate := range []outcome{{name: "work", key: "key-one"}, {name: "WORK", key: "key-two"}} { - candidate := candidate - go func() { - ready.Done() - <-start - _, err := CommitProviderProfile(path, ProviderCommit{Profile: ProviderProfile{Name: candidate.name, APIKey: candidate.key}}) - candidate.err = err - outcomes <- candidate - }() - } - ready.Wait() - close(start) - first, second := <-outcomes, <-outcomes - if (first.err == nil) == (second.err == nil) { - t.Fatalf("success count = %d, want 1", boolInt(first.err == nil)+boolInt(second.err == nil)) - } - winner := first - if winner.err != nil { - winner = second - } - loser := second - if loser.err == nil { - loser = first - } - if !strings.Contains(loser.err.Error(), "already exists as") { - t.Fatalf("loser error = %v", loser.err) - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - var cfg FileConfig - if err := json.Unmarshal(data, &cfg); err != nil { - t.Fatal(err) - } - if len(cfg.Providers) != 1 || cfg.Providers[0].Name != winner.name || !cfg.Providers[0].APIKeyStored { - t.Fatalf("persisted providers = %+v, want stored winner %q", cfg.Providers, winner.name) - } - store, err := ProviderKeyStoreAt(filepath.Dir(path)) - if err != nil { - t.Fatal(err) - } - key, ok, err := store.Get(winner.name) - if err != nil { - t.Fatal(err) - } - if !ok || key != winner.key { - t.Fatalf("winner credential present=%v matches=%v", ok, key == winner.key) - } -} - -func TestProviderCredentialSurvivesRequiresStoredMarker(t *testing.T) { - providers := []ProviderProfile{{Name: "WORK"}} - if ProviderCredentialSurvives(providers, "work") { - t.Fatal("markerless case-variant row must not retain the credential") - } - providers[0].APIKeyStored = true - if !ProviderCredentialSurvives(providers, "work") { - t.Fatal("stored-key case-variant row must retain the credential") - } -} - -func boolInt(value bool) int { - if value { - return 1 - } - return 0 -} diff --git a/internal/config/writer.go b/internal/config/writer.go index f59d3aeba..e71e0f217 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -149,6 +149,11 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC if path == "" { return FileConfig{}, fmt.Errorf("config path is required") } + profile.Name = strings.TrimSpace(profile.Name) + if profile.Name == "" { + return FileConfig{}, fmt.Errorf("provider name is required") + } + cfg := FileConfig{} if data, err := os.ReadFile(path); err == nil { if err := json.Unmarshal(data, &cfg); err != nil { @@ -160,26 +165,19 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC if err := ValidatePersistedProviderNames(cfg); err != nil { return FileConfig{}, err } - if err := upsertProviderConfig(&cfg, profile, setActive); err != nil { - return FileConfig{}, err - } - if err := writeConfigFile(path, cfg); err != nil { - return FileConfig{}, err - } - return cfg, nil -} - -func upsertProviderConfig(cfg *FileConfig, profile ProviderProfile, setActive bool) error { - profile.Name = strings.TrimSpace(profile.Name) - if profile.Name == "" { - return fmt.Errorf("provider name is required") - } for _, existing := range cfg.Providers { if sameProviderIdentity(existing.Name, profile.Name) && strings.TrimSpace(existing.Name) != profile.Name { - return fmt.Errorf("provider %q already exists as %q; provider names must be unique case-insensitively", profile.Name, existing.Name) + return FileConfig{}, fmt.Errorf("provider %q already exists as %q; provider names must be unique case-insensitively", profile.Name, existing.Name) } } - mergeProvider(cfg, profile) + + mergeProvider(&cfg, profile) + // mergeProfile deliberately ignores APIKeyStored — during resolve-time + // layering a project config must not be able to claim the user's stored + // keys. This user-config WRITE path re-applies the marker: capturing a key + // via SecureProviderProfile onto a previously env/no-key profile must + // persist apiKeyStored, or the secret sits in the credential store while + // every ApplyStoredAPIKey gate skips it (PR #560 review). if profile.APIKeyStored { for index := range cfg.Providers { if cfg.Providers[index].Name == profile.Name { @@ -191,7 +189,11 @@ func upsertProviderConfig(cfg *FileConfig, profile ProviderProfile, setActive bo if setActive || strings.TrimSpace(cfg.ActiveProvider) == "" { cfg.ActiveProvider = profile.Name } - return nil + + if err := writeConfigFile(path, cfg); err != nil { + return FileConfig{}, err + } + return cfg, nil } // EnsuredProvider reports the outcome of EnsureCatalogProvider: the profile name diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index 855628918..b6552777f 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -674,14 +674,19 @@ func oauthLoginName(profile config.ProviderProfile) (string, bool) { return strings.TrimPrefix(key, oauth.KeyPrefixProvider), true } +// savedProviderByName resolves a provider spelling to its saved profile using +// the credential store's own normalization rather than strings.EqualFold. The +// two disagree: EqualFold folds "s" and Unicode long-s "ſ" together, while the +// store keeps separate entries for them, so EqualFold could hand back a +// different provider's profile and reach its secret. func (m model) savedProviderByName(name string) (config.ProviderProfile, bool) { - name = strings.TrimSpace(name) + normalized := credstore.NormalizeProvider(name) for _, profile := range m.savedProviders { - if strings.TrimSpace(profile.Name) == name { + if credstore.NormalizeProvider(profile.Name) == normalized { return profile, true } } - if strings.TrimSpace(m.providerProfile.Name) == name { + if credstore.NormalizeProvider(m.providerProfile.Name) == normalized { return m.providerProfile, true } return config.ProviderProfile{}, false diff --git a/internal/tui/model.go b/internal/tui/model.go index df4819f28..9eb716dc6 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -21,6 +21,7 @@ import ( "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/credstore" "github.com/Gitlawb/zero/internal/doctor" "github.com/Gitlawb/zero/internal/errhint" "github.com/Gitlawb/zero/internal/lsp" @@ -4414,7 +4415,7 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) { text := "" owner := strings.TrimSpace(item.OwnerProvider) _, ownerIsSavedProvider := m.savedProviderByName(owner) - if owner != "" && owner != strings.TrimSpace(m.providerName) && ownerIsSavedProvider { + if owner != "" && credstore.NormalizeProvider(owner) != credstore.NormalizeProvider(m.providerName) && ownerIsSavedProvider { // A model from another saved provider: switch provider + model together. m, text, _, cmd = m.switchProviderModel(owner, item.Value) } else { diff --git a/internal/tui/provider_manager.go b/internal/tui/provider_manager.go index 280ea6d1f..9bd80dc7f 100644 --- a/internal/tui/provider_manager.go +++ b/internal/tui/provider_manager.go @@ -363,7 +363,7 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { return m, nil } activeAfter = cfg.ActiveProvider - deleteStoredKey := !config.ProviderCredentialSurvives(cfg.Providers, name) + deleteStoredKey := !providerIdentitySurvives(cfg.Providers, name) if deleteStoredKey { notes = []string{"Deleted " + name + ". Its stored API key will also be deleted."} } else { @@ -411,6 +411,15 @@ func removeSavedProvider(saved []config.ProviderProfile, name string) []config.P return kept } +func providerIdentitySurvives(providers []config.ProviderProfile, removedName string) bool { + for _, provider := range providers { + if config.SameProviderIdentity(provider.Name, removedName) { + return true + } + } + return false +} + func samePersistedProviderName(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) } diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index bcc882d7a..c65026592 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -1261,17 +1261,21 @@ func (m model) applyProviderWizard() (model, tea.Cmd) { nextProvider = built } if strings.TrimSpace(m.userConfigPath) != "" { + if err := config.PreflightProviderWrite(m.userConfigPath, profile.Name); err != nil { + wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{profile.APIKey, runtimeProfile.APIKey}}) + return m, nil + } + // Capture flip: move the freshly entered key into the encrypted credential + // store before persisting, so config.json never holds the cleartext. The + // provider was already built above from runtimeProfile, which has the key. secret := profile.APIKey - result, err := config.CommitProviderProfile(m.userConfigPath, config.ProviderCommit{ - Profile: profile, - SetActive: true, - KeepStoredKey: preserveExistingCredentialReference, - }) - if err != nil { + if !preserveExistingCredentialReference { + profile = config.SecureProviderProfile(profile, m.userConfigPath) + } + if _, err := config.UpsertProvider(m.userConfigPath, profile, true); err != nil { wizard.err = redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{secret, profile.APIKey}}) return m, nil // nothing committed to live state yet } - profile = result.Persisted } // Both succeeded — commit the live provider, profile, model, and the child From 4380999f1c242d95b20cd33713336630f826d9a5 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 16 Aug 2026 14:14:17 +0200 Subject: [PATCH 07/17] fix(provider): finish the identity contract at every consumer boundary Adds the shared helpers the identity split needs, then routes every CLI and TUI provider path through them instead of patching each boundary separately. internal/config: - ResolvePersistedProviderName bridges credential-identity input to the exact persisted row spelling that row-targeting mutators require (exact wins, identity is a fallback, ambiguity is an error). SetActiveProvider now uses it. - CredentialKeyRetained decides key retention by OWNERSHIP (a survivor with APIKeyStored), not by name survival, so a markerless case variant can no longer orphan a secret. ProviderKeyRetainedAfterRemoval answers the same question before mutating, for confirmation copy. - PublishProviderCredential owns validate -> capture -> publish and restores the previous stored key when publication is rejected, replacing hand-rolled Set + Mark + Delete rollbacks. - RemoveProvider re-points an activeProvider stranded on a third spelling. Consumers: - auth openrouter preflights before EnsureCatalogProvider and publishes through the transaction; a login that cannot be persisted now exits non-zero. - auth logout clears the marker before deleting the secret, and both halves use the store beside the config being edited. - providers remove/rename resolve user input to the exact row. - TUI manager delete, manager edit, wizard key removal, and model persistence resolve spellings the same way; wizard key removal clears the marker first and reconciles the live session; the delete confirmation is driven by the same retention predicate as the delete. - EqualFold audit: provider-name comparisons in auth, picker, wizard, wizard discovery, session summary, and EnsureCatalogProvider now use the credential store's rule or exact row spelling, by intent. Tests: a table-driven CLI+config+credstore identity matrix, plus regressions for OpenRouter key preservation, logout marker cleanup, retention policy, confirmation copy, session sync, and case-variant model persistence. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Pierre Bruno --- internal/cli/auth.go | 50 ++-- internal/cli/auth_test.go | 88 +++++++ internal/cli/provider_identity_matrix_test.go | 234 ++++++++++++++++++ internal/cli/provider_onboarding.go | 35 +-- internal/config/credentials.go | 53 ++++ internal/config/credentials_test.go | 95 +++++++ internal/config/writer.go | 134 ++++++++-- internal/config/writer_test.go | 158 ++++++++++++ internal/tui/command_center.go | 33 ++- internal/tui/command_center_test.go | 75 ++++++ internal/tui/picker.go | 2 +- internal/tui/provider_manager.go | 51 ++-- internal/tui/provider_manager_test.go | 101 ++++++++ internal/tui/provider_wizard.go | 74 ++++-- internal/tui/provider_wizard_discovery.go | 3 +- internal/tui/provider_wizard_test.go | 62 ++++- internal/tui/session.go | 6 +- 17 files changed, 1160 insertions(+), 94 deletions(-) create mode 100644 internal/cli/provider_identity_matrix_test.go create mode 100644 internal/tui/command_center_test.go diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 46302e7c0..f6dbfc1b1 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "os" - "path/filepath" "strings" "time" @@ -39,7 +38,9 @@ func ensureLoginProviderProfile(deps appDeps, provider string) string { if err != nil { return "warning: login saved, but no provider profile was written: " + err.Error() } - active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name)) + // Both sides are persisted provider names, so this is a provider-identity + // question and uses the credential store's rule rather than EqualFold. + active := config.SameProviderIdentity(ensured.Active, ensured.Name) switch { case ensured.Created && active: return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name) @@ -111,10 +112,13 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps a key = strings.TrimSpace(key) line, err := saveOpenRouterProviderKey(deps, key) if err != nil { + // The key was minted, so still hand it over for manual use — but a login + // that could not be persisted is a failure, not a success: exiting 0 here + // told scripts (and users) the provider was configured when it was not. if _, writeErr := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted, but Zero could not save it: %s\nUse it manually, e.g.:\n export OPENROUTER_API_KEY=%s\n", err, key); writeErr != nil { return exitCrash } - return exitSuccess + return exitCrash } if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key saved.\n%s\n", line); err != nil { return exitCrash @@ -131,24 +135,24 @@ func saveOpenRouterProviderKey(deps appDeps, key string) (string, error) { if err != nil { return "", err } - ensured, err := config.EnsureCatalogProvider(configPath, "openrouter") - if err != nil { + // Validate the persisted config BEFORE EnsureCatalogProvider's lookup, which + // matches case-insensitively and would happily return one of a pair of legacy + // duplicate rows — whose shared credential the capture below then overwrites + // for a config that can never be published. + if err := config.PreflightUserConfig(configPath); err != nil { return "", err } - store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) + ensured, err := config.EnsureCatalogProvider(configPath, "openrouter") if err != nil { return "", err } - if err := store.Set(ensured.Name, key); err != nil { - return "", err - } - if err := config.MarkProviderAPIKeyStored(configPath, ensured.Name); err != nil { - // Best-effort rollback: don't leave the key orphaned in the credential - // store while config.json still says it isn't there. - _, _ = store.Delete(ensured.Name) + // One operation owns validate → capture → publish, and restores the previous + // stored key if publication is rejected, so a failed login never costs the + // user the key they were already working with. + if err := config.PublishProviderCredential(configPath, ensured.Name, key); err != nil { return "", err } - active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name)) + active := config.SameProviderIdentity(ensured.Active, ensured.Name) switch { case ensured.Created && active: return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name), nil @@ -456,15 +460,25 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe // Also drop any stored API key and its marker so `auth logout` clears the whole // credential (OAuth token AND key), not just the OAuth side. Surface deletion // failures rather than reporting success while a credential remains. - keyRemoved, keyErr := config.ForgetProviderKey(provider) - if keyErr != nil { - return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash) - } + // Marker first, then the secret: the reverse order leaves apiKeyStored:true + // with nothing behind it if the config write fails. Both halves address the + // store BESIDE the config being edited (where setup/rename captured the key), + // so a non-default config path cannot clear a marker here while the secret + // stays in the default-path store. if configPath != "" { if _, clearErr := config.ClearProviderKeyStoredCaseVariants(configPath, provider); clearErr != nil { return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash) } } + keyRemoved, keyErr := false, error(nil) + if configPath != "" { + keyRemoved, keyErr = removeStoredProviderKeyAt(configPath, provider) + } else { + keyRemoved, keyErr = config.ForgetProviderKey(provider) + } + if keyErr != nil { + return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash) + } removed = removed || keyRemoved if parsed.json { payload := struct { diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 7291d7c2c..1c1dd3ead 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -414,3 +414,91 @@ func TestRunAuthLogoutRejectsAmbiguousConfigBeforeCredentialDeletion(t *testing. t.Fatal("rejected logout rewrote ambiguous config") } } + +// A legacy duplicate-row config cannot be published, and the rejection must not +// cost the user the OpenRouter key they were already working with: the capture +// is validated first, and a rejected publication restores the previous secret +// rather than deleting the shared entry. +func TestRunAuthOpenRouterPreservesExistingKeyWhenConfigRejected(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + seed := `{"activeProvider":"openrouter","providers":[{"name":"openrouter","apiKeyStored":true},{"name":"OPENROUTER","apiKeyStored":true}]}` + if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if err := store.Set("openrouter", "sk-working"); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := runWithDeps([]string{"auth", "openrouter"}, &stdout, &stderr, appDeps{ + userConfigPath: func() (string, error) { return configPath, nil }, + openRouterLogin: func(context.Context, provideroauth.OpenRouterOptions) (string, error) { + return "sk-minted", nil + }, + }) + + // A login that could not be persisted is a failure, not a success. + if code == exitSuccess { + t.Fatalf("exit = %d, want non-zero for an unsaved login: %s", code, stdout.String()) + } + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(after) != seed { + t.Fatalf("rejected login rewrote config:\n%s", after) + } + key, ok, err := store.Get("openrouter") + if err != nil { + t.Fatal(err) + } + if !ok || key != "sk-working" { + t.Fatalf("stored key = %q (present=%v), want the previous sk-working preserved", key, ok) + } + // The minted key is still handed over for manual use. + if !strings.Contains(stdout.String(), "sk-minted") { + t.Fatalf("stdout = %q, want the manual-export hint with the minted key", stdout.String()) + } +} + +// auth logout deletes the secret by normalized identity, so the marker cleanup +// must use the same relation: a mixed-case argument against a lowercase row +// previously left apiKeyStored:true with no secret behind it. +func TestRunAuthLogoutClearsMarkerForCaseVariantSpelling(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + withAuthStore(t) + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":[{"name":"work","apiKeyStored":true}]}`), 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "sk-work"); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + if code := runWithDeps([]string{"auth", "logout", "WORK"}, &stdout, &stderr, appDeps{ + userConfigPath: func() (string, error) { return configPath, nil }, + }); code != exitSuccess { + t.Fatalf("exit = %d, stderr = %q", code, stderr.String()) + } + cfg := readCLIConfigFixture(t, configPath) + if cfg.Providers[0].APIKeyStored { + t.Fatal("logout left a marker claiming a credential it deleted") + } + if _, ok, err := store.Get("work"); err != nil { + t.Fatal(err) + } else if ok { + t.Fatal("logout left the stored key behind") + } +} diff --git a/internal/cli/provider_identity_matrix_test.go b/internal/cli/provider_identity_matrix_test.go new file mode 100644 index 000000000..13536d60b --- /dev/null +++ b/internal/cli/provider_identity_matrix_test.go @@ -0,0 +1,234 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" +) + +// providerIdentityFixture seeds a config file plus the credential store beside +// it, and hands back the config path. Every row of the matrix below starts from +// one of these so the CLI command, the config writer, and the credential store +// all see the same on-disk world. +type providerIdentityFixture struct { + // configJSON is written verbatim: these scenarios need spellings and + // duplicate rows that FileConfig round-tripping would not preserve. + configJSON string + // storedKeys are seeded into the credential store co-located with the config. + storedKeys map[string]string +} + +func seedProviderIdentityFixture(t *testing.T, fixture providerIdentityFixture) string { + t.Helper() + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(fixture.configJSON), 0o600); err != nil { + t.Fatalf("seed config: %v", err) + } + if len(fixture.storedKeys) > 0 { + store, err := config.ProviderKeyStoreAt(dir) + if err != nil { + t.Fatalf("open credential store: %v", err) + } + for provider, key := range fixture.storedKeys { + if err := store.Set(provider, key); err != nil { + t.Fatalf("seed key for %q: %v", provider, err) + } + } + } + return configPath +} + +func storedProviderKey(t *testing.T, configPath string, provider string) (string, bool) { + t.Helper() + + store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) + if err != nil { + t.Fatalf("open credential store: %v", err) + } + key, ok, err := store.Get(provider) + if err != nil { + t.Fatalf("read key for %q: %v", provider, err) + } + return key, ok +} + +// TestProviderIdentityMatrix is the invariant test for this slice's contract: +// user input is matched by CREDENTIAL IDENTITY, persisted rows are mutated by +// EXACT SPELLING, a stored key survives only while a remaining row still claims +// it, and nothing is captured before the config it belongs to validates. Each +// row is one boundary where those rules previously disagreed; adding a row is +// how a future boundary gets covered, rather than another per-finding test. +func TestProviderIdentityMatrix(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + + t.Run("case-variant remove targets the sole persisted row", func(t *testing.T) { + configPath := seedProviderIdentityFixture(t, providerIdentityFixture{ + configJSON: `{"activeProvider":"WORK","providers":[{"name":"WORK","apiKeyStored":true}]}`, + storedKeys: map[string]string{"WORK": "sk-work"}, + }) + var stdout, stderr bytes.Buffer + + if code := runWithDeps([]string{"providers", "remove", "work"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitSuccess { + t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String()) + } + if cfg := readFileConfig(t, configPath); len(cfg.Providers) != 0 { + t.Fatalf("providers = %#v, want the row removed", cfg.Providers) + } + if key, ok := storedProviderKey(t, configPath, "work"); ok { + t.Fatalf("stored key %q survived removal of its only owner", key) + } + }) + + t.Run("case-variant rename targets the sole persisted row", func(t *testing.T) { + configPath := seedProviderIdentityFixture(t, providerIdentityFixture{ + configJSON: `{"activeProvider":"WORK","providers":[{"name":"WORK"}]}`, + }) + var stdout, stderr bytes.Buffer + + if code := runWithDeps([]string{"providers", "rename", "work", "acme"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitSuccess { + t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String()) + } + cfg := readFileConfig(t, configPath) + if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "acme" || cfg.ActiveProvider != "acme" { + t.Fatalf("config = %#v, want the row and active pointer renamed to acme", cfg) + } + }) + + t.Run("ambiguous duplicate rows are rejected before any mutation", func(t *testing.T) { + seed := `{"activeProvider":"work","providers":[{"name":"work","apiKeyStored":true},{"name":"WORK"}]}` + configPath := seedProviderIdentityFixture(t, providerIdentityFixture{ + configJSON: seed, + storedKeys: map[string]string{"work": "sk-work"}, + }) + var stdout, stderr bytes.Buffer + + // "Work" matches neither row exactly and both by identity. + if code := runWithDeps([]string{"providers", "remove", "Work"}, &stdout, &stderr, providerSetupDeps(configPath)); code == exitSuccess { + t.Fatalf("ambiguous removal reported success: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "ambiguous provider") { + t.Fatalf("stderr = %q, want an ambiguity error", stderr.String()) + } + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(after) != seed { + t.Fatalf("rejected removal rewrote config:\n%s", after) + } + if key, ok := storedProviderKey(t, configPath, "work"); !ok || key != "sk-work" { + t.Fatalf("stored key = %q (present=%v), want sk-work untouched", key, ok) + } + }) + + t.Run("removing a row whose case variant still claims the key keeps it", func(t *testing.T) { + configPath := seedProviderIdentityFixture(t, providerIdentityFixture{ + configJSON: `{"activeProvider":"work","providers":[{"name":"work","apiKeyStored":true},{"name":"WORK","apiKeyStored":true}]}`, + storedKeys: map[string]string{"work": "sk-shared"}, + }) + var stdout, stderr bytes.Buffer + + if code := runWithDeps([]string{"providers", "remove", "work"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitSuccess { + t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String()) + } + cfg := readFileConfig(t, configPath) + if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "WORK" || !cfg.Providers[0].APIKeyStored { + t.Fatalf("config = %#v, want WORK surviving with its marker", cfg) + } + key, ok := storedProviderKey(t, configPath, "WORK") + if !ok || key != "sk-shared" { + t.Fatalf("stored key = %q (present=%v), want the survivor's sk-shared kept", key, ok) + } + // The survivor must actually be able to load it. + store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) + if err != nil { + t.Fatal(err) + } + if loaded := config.ApplyStoredAPIKey(cfg.Providers[0], store); strings.TrimSpace(loaded.APIKey) != "sk-shared" { + t.Fatalf("survivor loaded APIKey = %q, want sk-shared", loaded.APIKey) + } + }) + + t.Run("removing the only row that claims the key deletes it", func(t *testing.T) { + configPath := seedProviderIdentityFixture(t, providerIdentityFixture{ + configJSON: `{"activeProvider":"work","providers":[{"name":"work","apiKeyStored":true},{"name":"WORK"}]}`, + storedKeys: map[string]string{"work": "sk-shared"}, + }) + var stdout, stderr bytes.Buffer + + if code := runWithDeps([]string{"providers", "remove", "work"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitSuccess { + t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String()) + } + // The surviving WORK row never claimed the credential, so keeping the + // secret would only orphan it behind a marker ApplyStoredAPIKey skips. + if key, ok := storedProviderKey(t, configPath, "WORK"); ok { + t.Fatalf("stored key %q was orphaned behind a markerless survivor", key) + } + if !strings.Contains(stdout.String(), "Deleted its stored API key.") { + t.Fatalf("stdout = %q, want the key-deletion note", stdout.String()) + } + }) + + t.Run("repair removal re-points a stale activeProvider spelling", func(t *testing.T) { + configPath := seedProviderIdentityFixture(t, providerIdentityFixture{ + configJSON: `{"activeProvider":"WoRk","providers":[{"name":"work"},{"name":"WORK"}]}`, + }) + var stdout, stderr bytes.Buffer + + if code := runWithDeps([]string{"providers", "remove", "WORK"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitSuccess { + t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String()) + } + cfg := readFileConfig(t, configPath) + if cfg.ActiveProvider != "work" { + t.Fatalf("activeProvider = %q, want the surviving row's spelling work", cfg.ActiveProvider) + } + // The exact mutators must be able to find it again. + if _, err := config.SetProviderModel(configPath, cfg.ActiveProvider, "gpt-4"); err != nil { + t.Fatalf("exact mutator cannot address the repaired active row: %v", err) + } + }) + + t.Run("case-variant use activates the persisted row", func(t *testing.T) { + configPath := seedProviderIdentityFixture(t, providerIdentityFixture{ + configJSON: `{"activeProvider":"other","providers":[{"name":"other"},{"name":"OpenAI"}]}`, + }) + var stdout, stderr bytes.Buffer + + if code := runWithDeps([]string{"providers", "use", "openai"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitSuccess { + t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String()) + } + if cfg := readFileConfig(t, configPath); cfg.ActiveProvider != "OpenAI" { + t.Fatalf("activeProvider = %q, want the row's own spelling OpenAI", cfg.ActiveProvider) + } + }) + + t.Run("unicode long-s stays a distinct identity end to end", func(t *testing.T) { + configPath := seedProviderIdentityFixture(t, providerIdentityFixture{ + configJSON: "{\"activeProvider\":\"s\",\"providers\":[{\"name\":\"s\",\"apiKeyStored\":true},{\"name\":\"ſ\",\"apiKeyStored\":true}]}", + storedKeys: map[string]string{"s": "sk-latin", "ſ": "sk-long"}, + }) + var stdout, stderr bytes.Buffer + + if code := runWithDeps([]string{"providers", "remove", "s"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitSuccess { + t.Fatalf("exit = %d, want %d: %s", code, exitSuccess, stderr.String()) + } + // strings.EqualFold folds these two together; the credential store does + // not, so removing "s" must not reach the long-s profile or its secret. + cfg := readFileConfig(t, configPath) + if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "ſ" { + t.Fatalf("config = %#v, want only the long-s row remaining", cfg) + } + if key, ok := storedProviderKey(t, configPath, "ſ"); !ok || key != "sk-long" { + t.Fatalf("long-s key = %q (present=%v), want sk-long untouched", key, ok) + } + if key, ok := storedProviderKey(t, configPath, "s"); ok { + t.Fatalf("latin-s key %q survived removal of its only owner", key) + } + }) +} diff --git a/internal/cli/provider_onboarding.go b/internal/cli/provider_onboarding.go index 320a1d2e4..677b4964a 100644 --- a/internal/cli/provider_onboarding.go +++ b/internal/cli/provider_onboarding.go @@ -414,15 +414,25 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps return exit } } + // ProviderPersisted above answers a credential-identity question, but + // RemoveProvider targets a row by its exact spelling. Bridge the two, so + // `zero providers remove work` against a sole saved "WORK" row removes it + // instead of failing "not found" right after the persisted check passed. + name, err = config.ResolvePersistedProviderName(configPath, name) + if err != nil { + return writeAppError(stderr, err.Error(), exitCrash) + } cfg, err := config.RemoveProvider(configPath, name) if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } // Delete the key from the store BESIDE the config being edited — the same // store setup/rename write to — not the default-path store, so a - // non-default config path cannot leave the encrypted key behind. + // non-default config path cannot leave the encrypted key behind. A surviving + // case variant that still claims the credential keeps it (see + // config.CredentialKeyRetained); a survivor that never claimed it does not. keyRemoved, keyErr := false, error(nil) - if !providerIdentitySurvives(cfg.Providers, name) { + if !config.CredentialKeyRetained(cfg.Providers, name) { keyRemoved, keyErr = removeStoredProviderKeyAt(configPath, name) } if options.json { @@ -476,15 +486,6 @@ func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) return store.Delete(provider) } -func providerIdentitySurvives(providers []config.ProviderProfile, removedName string) bool { - for _, provider := range providers { - if config.SameProviderIdentity(provider.Name, removedName) { - return true - } - } - return false -} - // runProvidersRename renames a saved provider profile, migrating its stored // API key and the activeProvider pointer along with it (config.RenameProvider). func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { @@ -512,13 +513,19 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps return exit } } - cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1]) + // Same bridge as remove: the persisted check matches credential identity + // while RenameProvider matches the row's exact spelling. + oldName, err = config.ResolvePersistedProviderName(configPath, oldName) + if err != nil { + return writeAppError(stderr, err.Error(), exitCrash) + } + cfg, err := config.RenameProvider(configPath, oldName, options.names[1]) if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } if options.json { if err := writePrettyJSON(stdout, map[string]any{ - "renamed": map[string]string{"from": options.names[0], "to": options.names[1]}, + "renamed": map[string]string{"from": oldName, "to": options.names[1]}, "activeProvider": cfg.ActiveProvider, "configPath": configPath, }); err != nil { @@ -526,7 +533,7 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps } return exitSuccess } - if _, err := fmt.Fprintf(stdout, "Renamed provider %s to %s\n", options.names[0], options.names[1]); err != nil { + if _, err := fmt.Fprintf(stdout, "Renamed provider %s to %s\n", oldName, options.names[1]); err != nil { return exitCrash } return exitSuccess diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 23f48e1f3..fc3b3f427 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -66,6 +66,59 @@ func SecureProviderProfile(profile ProviderProfile, configPath string) ProviderP return secured } +// PublishProviderCredential captures key into the credential store beside path +// and publishes the matching APIKeyStored marker for exactName as ONE +// operation, so a rejected publication cannot leave the user worse off than +// before the call. +// +// Hand-rolled Set-then-Mark sequences got this wrong in both directions: they +// wrote the secret before any validation could reject the config, and their +// rollback deleted the entry outright — destroying a working key that some +// other row (the store folds "openrouter" and "OPENROUTER" onto one entry) was +// still using. This validates first, snapshots whatever the store held, and on +// a marker failure restores that snapshot rather than deleting. +// +// exactName must be a persisted row's own spelling; callers holding user or +// session input resolve it with ResolvePersistedProviderName first. +func PublishProviderCredential(path string, exactName string, key string) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("config path is required") + } + exactName = strings.TrimSpace(exactName) + if exactName == "" { + return fmt.Errorf("provider name is required") + } + if strings.TrimSpace(key) == "" { + return fmt.Errorf("api key is required") + } + if err := PreflightUserConfig(path); err != nil { + return err + } + store, err := ProviderKeyStoreAt(filepath.Dir(path)) + if err != nil { + return err + } + previous, hadPrevious, err := store.Get(exactName) + if err != nil { + return err + } + if err := store.Set(exactName, key); err != nil { + return err + } + if err := MarkProviderAPIKeyStored(path, exactName); err != nil { + // Put the store back exactly as it was: restore a prior key rather than + // deleting it, and only delete when this call created the entry. + if hadPrevious { + _ = store.Set(exactName, previous) + } else { + _, _ = store.Delete(exactName) + } + return err + } + return nil +} + // ForgetProviderKey removes a provider's stored API key from the credential store, // reporting whether one existed. Used by the lifecycle "remove key" / auth logout. func ForgetProviderKey(provider string) (bool, error) { diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go index 389428222..38c5bb250 100644 --- a/internal/config/credentials_test.go +++ b/internal/config/credentials_test.go @@ -371,3 +371,98 @@ func TestClearProviderKeyStoredCaseVariantsPreservesDistinctUnicodeIdentity(t *t t.Fatal("long-s marker belongs to a distinct credential-store identity and must remain set") } } + +// A rejected publication must leave the user exactly where they started: the +// previous working key intact, not deleted by a rollback that assumed this +// call had created the entry. +func TestPublishProviderCredentialRestoresPreviousKeyWhenMarkerRejected(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + // Legacy duplicate rows: the write-time validator rejects this config, so + // the marker publication fails after the credential has been captured. + original := []byte(`{"providers":[{"name":"openrouter","apiKeyStored":true},{"name":"OPENROUTER","apiKeyStored":true}]}`) + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + store, err := ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if err := store.Set("openrouter", "sk-working"); err != nil { + t.Fatal(err) + } + + if err := PublishProviderCredential(path, "openrouter", "sk-new"); err == nil { + t.Fatal("publication must be rejected for an ambiguous persisted config") + } + + key, ok, err := store.Get("openrouter") + if err != nil { + t.Fatal(err) + } + if !ok || key != "sk-working" { + t.Fatalf("stored key = %q (present=%v), want the previous sk-working restored", key, ok) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(original) { + t.Fatalf("config was rewritten by a rejected publication:\n%s", after) + } +} + +// When the call created the entry there is nothing to restore, so a rejected +// publication must not leave an orphaned secret behind either. +func TestPublishProviderCredentialDeletesEntryItCreatedWhenMarkerRejected(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"providers":[{"name":"work"},{"name":"WORK"}]}`), 0o600); err != nil { + t.Fatal(err) + } + if err := PublishProviderCredential(path, "work", "sk-new"); err == nil { + t.Fatal("publication must be rejected for an ambiguous persisted config") + } + store, err := ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if _, ok, err := store.Get("work"); err != nil { + t.Fatal(err) + } else if ok { + t.Fatal("rejected publication left an orphaned secret in the store") + } +} + +func TestPublishProviderCredentialStoresAndMarks(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openrouter","apiKeyEnv":"OPENROUTER_API_KEY"}]}`), 0o600); err != nil { + t.Fatal(err) + } + if err := PublishProviderCredential(path, "openrouter", "sk-new"); err != nil { + t.Fatal(err) + } + store, err := ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + key, ok, err := store.Get("openrouter") + if err != nil || !ok || key != "sk-new" { + t.Fatalf("stored key = %q (present=%v, err=%v), want sk-new", key, ok, err) + } + var cfg FileConfig + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + if !cfg.Providers[0].APIKeyStored || strings.TrimSpace(cfg.Providers[0].APIKeyEnv) != "" { + t.Fatalf("marker not published: %+v", cfg.Providers[0]) + } +} diff --git a/internal/config/writer.go b/internal/config/writer.go index e71e0f217..0604fa417 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -109,21 +109,97 @@ func PreflightProviderWrite(path, name string) error { return nil } -// PersistedProviderNames returns the exact name of every row in the persisted -// user config, in file order. Callers that must reason about the SET of saved -// rows — e.g. deciding whether removing one row leaves a case variant behind -// that still reads the same credential-store entry — get the raw names here -// rather than re-implementing FileConfig parsing. -func PersistedProviderNames(path string) ([]string, error) { +// ResolvePersistedProviderName maps a user- or session-supplied provider +// spelling to the EXACT name of the persisted row it addresses, so a caller +// that gated on credential identity (ProviderPersisted, a resolved provider +// list, a live session's provider name) can hand a row-targeting mutator +// (RemoveProvider, RenameProvider, EditProvider, SetProviderModel, +// MarkProviderAPIKeyStored) a spelling those mutators can actually find. +// +// It is the one bridge between the two identity rules this package defines: +// an exact spelling always wins, credential identity is a fallback, and an +// identity that matches more than one row is an error rather than an +// arbitrary pick — the same ambiguity ValidatePersistedProviderNames rejects +// at write time, reported here for configs that predate that validation. +func ResolvePersistedProviderName(path string, input string) (string, error) { + providers, err := persistedProviders(path) + if err != nil { + return "", err + } + return resolvePersistedProviderName(providers, input) +} + +func resolvePersistedProviderName(providers []ProviderProfile, input string) (string, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", fmt.Errorf("provider name is required") + } + match := "" + matches := 0 + for _, provider := range providers { + name := strings.TrimSpace(provider.Name) + if name == input { + return name, nil + } + if sameProviderIdentity(name, input) { + match = name + matches++ + } + } + switch { + case matches == 1: + return match, nil + case matches > 1: + return "", fmt.Errorf("ambiguous provider %q: %d rows in config.json differ only by case; rename or remove one row", input, matches) + default: + return "", fmt.Errorf("provider %q not found", input) + } +} + +// CredentialKeyRetained reports whether, after removedName's row is gone, some +// REMAINING row still owns the credential-store entry that row pointed at — so +// deleting the shared secret would break a profile the user did not remove. +// +// Ownership is the marker, not the name: the store normalizes "work" and +// "WORK" to one entry, but a surviving row with apiKeyStored:false never reads +// it (ApplyStoredAPIKey gates on the marker), so keeping the secret for that +// row would only orphan it. Retain the key when a survivor actually claims it; +// otherwise the removal took the last owner with it and the key must go. +func CredentialKeyRetained(providers []ProviderProfile, removedName string) bool { + removedName = strings.TrimSpace(removedName) + if removedName == "" { + return false + } + for _, provider := range providers { + if provider.APIKeyStored && sameProviderIdentity(strings.TrimSpace(provider.Name), removedName) { + return true + } + } + return false +} + +// ProviderKeyRetainedAfterRemoval answers CredentialKeyRetained's question +// against the config on disk, simulating the removal of name's row. Callers +// that must know the outcome BEFORE mutating anything use this — a delete +// confirmation has to promise exactly what the delete will do, and computing +// it from a different rule is how the prompt came to claim "this also removes +// its stored API key" for a delete that keeps the key. +func ProviderKeyRetainedAfterRemoval(path string, name string) (bool, error) { providers, err := persistedProviders(path) if err != nil { - return nil, err + return false, err } - names := make([]string, 0, len(providers)) + name = strings.TrimSpace(name) + remaining := make([]ProviderProfile, 0, len(providers)) + removed := false for _, provider := range providers { - names = append(names, strings.TrimSpace(provider.Name)) + if !removed && strings.TrimSpace(provider.Name) == name { + removed = true + continue + } + remaining = append(remaining, provider) } - return names, nil + return CredentialKeyRetained(remaining, name), nil } // persistedProviders reads the provider rows out of the user config at path. @@ -232,8 +308,10 @@ func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, erro return EnsuredProvider{}, fmt.Errorf("read config %s: %w", path, err) } for _, provider := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(provider.CatalogID), descriptor.ID) || - strings.EqualFold(strings.TrimSpace(provider.Name), descriptor.ID) { + // Which persisted row already serves this catalog entry is a provider + // identity question, so it uses the credential store's rule. + if sameProviderIdentity(strings.TrimSpace(provider.CatalogID), descriptor.ID) || + sameProviderIdentity(strings.TrimSpace(provider.Name), descriptor.ID) { return EnsuredProvider{Name: provider.Name, Active: cfg.ActiveProvider}, nil } } @@ -308,17 +386,19 @@ func SetActiveProvider(path string, name string) (FileConfig, error) { return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) } - for _, provider := range cfg.Providers { - if sameProviderIdentity(provider.Name, name) { - cfg.ActiveProvider = provider.Name - if err := writeConfigFile(path, cfg); err != nil { - return FileConfig{}, err - } - return cfg, nil - } + // Activation accepts any spelling that names this credential identity, then + // records the row's own spelling so every later row-targeting mutator can + // find it. ResolvePersistedProviderName is that bridge — see its doc for + // why an ambiguous identity is an error rather than an arbitrary pick. + resolved, err := resolvePersistedProviderName(cfg.Providers, name) + if err != nil { + return FileConfig{}, err } - - return FileConfig{}, fmt.Errorf("provider %q not found", name) + cfg.ActiveProvider = resolved + if err := writeConfigFile(path, cfg); err != nil { + return FileConfig{}, err + } + return cfg, nil } // ProviderPersisted reports whether a provider profile named name actually has @@ -407,6 +487,16 @@ func RemoveProvider(path string, name string) (FileConfig, error) { if len(cfg.Providers) > 0 { cfg.ActiveProvider = cfg.Providers[0].Name } + } else if active := strings.TrimSpace(cfg.ActiveProvider); active != "" { + // Repairing a case-duplicate config can strand activeProvider on a third + // spelling ("WoRk" with rows "work"/"WORK"): the pointer survived the + // removal but now matches no remaining row exactly, so every exact + // mutator fails until the user hand-edits config.json. Re-point it at the + // survivor's own spelling when exactly one row still carries the + // identity; leave it alone when it is still ambiguous or already exact. + if resolved, resolveErr := resolvePersistedProviderName(cfg.Providers, active); resolveErr == nil { + cfg.ActiveProvider = resolved + } } if err := writeConfigFile(path, cfg); err != nil { return FileConfig{}, err diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go index d5cf46666..a345872f6 100644 --- a/internal/config/writer_test.go +++ b/internal/config/writer_test.go @@ -1295,3 +1295,161 @@ func TestValidatePersistedProviderNamesRejectsImplicitOpenAICollision(t *testing t.Fatalf("error = %v, want empty persisted-provider name rejection", err) } } + +func TestResolvePersistedProviderNameBridgesIdentityToExactSpelling(t *testing.T) { + cases := []struct { + name string + providers []ProviderProfile + input string + want string + wantErr string + }{ + { + name: "exact spelling", + providers: []ProviderProfile{{Name: "OpenAI"}}, + input: "OpenAI", + want: "OpenAI", + }, + { + name: "case variant resolves to the row's own spelling", + providers: []ProviderProfile{{Name: "WORK"}}, + input: "work", + want: "WORK", + }, + { + name: "exact spelling wins over an earlier case variant", + providers: []ProviderProfile{{Name: "WORK"}, {Name: "work"}}, + input: "work", + want: "work", + }, + { + name: "ambiguous identity is an error, not an arbitrary pick", + providers: []ProviderProfile{{Name: "WORK"}, {Name: "Work"}}, + input: "work", + wantErr: "ambiguous provider", + }, + { + // The credential store keeps "s" and Unicode long-s apart, so these + // are two identities and neither resolves the other. + name: "unicode long-s is a distinct identity", + providers: []ProviderProfile{{Name: "ſ"}}, + input: "s", + wantErr: "not found", + }, + { + name: "unknown name", + providers: []ProviderProfile{{Name: "openai"}}, + input: "anthropic", + wantErr: "not found", + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + writeConfigFixture(t, path, FileConfig{Providers: testCase.providers}, 0o600) + got, err := ResolvePersistedProviderName(path, testCase.input) + if testCase.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("error = %v, want containing %q", err, testCase.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got != testCase.want { + t.Fatalf("resolved = %q, want %q", got, testCase.want) + } + }) + } +} + +// Credential ownership is the marker, not the name: a surviving case variant +// that never claimed the shared key cannot keep it alive, or the secret is +// orphaned behind a profile ApplyStoredAPIKey will never read. +func TestCredentialKeyRetainedRequiresASurvivingOwner(t *testing.T) { + cases := []struct { + name string + providers []ProviderProfile + removed string + want bool + }{ + { + name: "survivor claims the credential", + providers: []ProviderProfile{{Name: "WORK", APIKeyStored: true}}, + removed: "work", + want: true, + }, + { + name: "survivor exists but never claimed the credential", + providers: []ProviderProfile{{Name: "WORK"}}, + removed: "work", + want: false, + }, + { + name: "no survivor shares the identity", + providers: []ProviderProfile{{Name: "other", APIKeyStored: true}}, + removed: "work", + want: false, + }, + { + name: "unicode long-s does not share the identity", + providers: []ProviderProfile{{Name: "ſ", APIKeyStored: true}}, + removed: "s", + want: false, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := CredentialKeyRetained(testCase.providers, testCase.removed); got != testCase.want { + t.Fatalf("CredentialKeyRetained = %v, want %v", got, testCase.want) + } + }) + } +} + +// The delete confirmation must be able to promise exactly what the delete does, +// so the pre-mutation answer has to match the post-mutation one. +func TestProviderKeyRetainedAfterRemovalMatchesPostRemovalAnswer(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + writeConfigFixture(t, path, FileConfig{ + Providers: []ProviderProfile{{Name: "work", APIKeyStored: true}, {Name: "WORK", APIKeyStored: true}}, + }, 0o600) + + before, err := ProviderKeyRetainedAfterRemoval(path, "work") + if err != nil { + t.Fatal(err) + } + if !before { + t.Fatal("pre-removal answer = false, want true (WORK still claims the credential)") + } + cfg, err := RemoveProvider(path, "work") + if err != nil { + t.Fatal(err) + } + if after := CredentialKeyRetained(cfg.Providers, "work"); after != before { + t.Fatalf("post-removal answer = %v, want %v", after, before) + } +} + +// Repairing a case-duplicate config can leave activeProvider on a third +// spelling that matches no remaining row exactly, which every exact mutator +// then fails against. Removal re-points it at the survivor's own spelling. +func TestRemoveProviderNormalizesStaleActiveProviderSpelling(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "WoRk", + Providers: []ProviderProfile{{Name: "work"}, {Name: "WORK"}}, + }, 0o600) + + cfg, err := RemoveProvider(path, "WORK") + if err != nil { + t.Fatal(err) + } + if cfg.ActiveProvider != "work" { + t.Fatalf("activeProvider = %q, want the surviving row's spelling work", cfg.ActiveProvider) + } + if _, err := SetProviderModel(path, cfg.ActiveProvider, "gpt-4"); err != nil { + t.Fatalf("exact mutator still cannot find the active row: %v", err) + } +} diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index b6552777f..3c44167fc 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -580,9 +580,28 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string, ) // Keep sub-agent child processes on the same provider we just switched to. config.SetActiveProviderEnv(target.Name) + persistNote := "" if strings.TrimSpace(m.userConfigPath) != "" { - _, _ = config.SetActiveProvider(m.userConfigPath, target.Name) - _, _ = config.SetProviderModel(m.userConfigPath, target.Name, target.Model) + // SetActiveProvider accepts any spelling of the credential identity and + // returns the config with the persisted row's OWN spelling in + // ActiveProvider. SetProviderModel matches rows exactly, so persist with + // that resolved name — passing the session's spelling silently wrote + // nothing whenever the two differed (session "openai", row "OpenAI"). + // + // Env-derived providers have no row to update, so they are skipped + // silently; a failure to write a row that DOES exist is surfaced rather + // than swallowed, since the session and config.json then disagree. + persisted, err := config.ProviderPersisted(m.userConfigPath, target.Name) + switch { + case err != nil: + persistNote = "\nNote: the switch applies to this session, but config.json could not be read: " + redaction.RedactString(err.Error(), redaction.Options{}) + case persisted: + if cfg, err := config.SetActiveProvider(m.userConfigPath, target.Name); err != nil { + persistNote = "\nNote: the switch applies to this session, but config.json was not updated: " + redaction.RedactString(err.Error(), redaction.Options{}) + } else if _, err := config.SetProviderModel(m.userConfigPath, cfg.ActiveProvider, target.Model); err != nil { + persistNote = "\nNote: the active provider was saved, but its model was not: " + redaction.RedactString(err.Error(), redaction.Options{}) + } + } } // Warm discovery for the provider we just switched to, same as Init() does // for the provider active at launch — otherwise the context-usage gauge has @@ -597,6 +616,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string, } } status := fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model) + status += persistNote if warn := m.visionDropWarning(); warn != "" { status += "\n" + warn } @@ -713,7 +733,14 @@ func (m model) persistSelectedModel(profile config.ProviderProfile) (bool, error // Env-derived providers have no config.json row to update. return false, nil } - if _, err := config.SetProviderModel(path, name, model); err != nil { + // ProviderPersisted matches credential identity; SetProviderModel matches + // the row exactly. Resolve the session's spelling to the row's own before + // writing, or a case difference makes this a silent no-op. + exactName, err := config.ResolvePersistedProviderName(path, name) + if err != nil { + return false, err + } + if _, err := config.SetProviderModel(path, exactName, model); err != nil { return false, err } return true, nil diff --git a/internal/tui/command_center_test.go b/internal/tui/command_center_test.go new file mode 100644 index 000000000..161b7e8b7 --- /dev/null +++ b/internal/tui/command_center_test.go @@ -0,0 +1,75 @@ +package tui + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// The session's provider spelling can differ from the persisted row's +// ("openai" vs a saved "OpenAI"). Activation matches credential identity while +// the model write matches the row exactly, so the model write has to use the +// resolved spelling or it silently persists nothing. +func TestModelPersistenceUsesResolvedPersistedSpelling(t *testing.T) { + newConfig := func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"activeProvider":"OpenAI","providers":[{"name":"OpenAI","catalogID":"openai","model":"gpt-5.1"},{"name":"ollama","catalogID":"ollama","provider_kind":"openai-compatible","baseURL":"http://localhost:11434/v1","model":"m1"}]}`), 0o600); err != nil { + t.Fatal(err) + } + return path + } + + t.Run("persistSelectedModel", func(t *testing.T) { + configPath := newConfig(t) + m := newModel(context.Background(), Options{UserConfigPath: configPath}) + // The session spelling "openai" addresses the persisted "OpenAI" row. + persisted, err := m.persistSelectedModel(config.ProviderProfile{Name: "openai", Model: "gpt-5.5"}) + if err != nil { + t.Fatal(err) + } + if !persisted { + t.Fatal("persistSelectedModel reported no write for a persisted case variant") + } + cfg := readTUIConfigFixture(t, configPath) + if cfg.Providers[0].Model != "gpt-5.5" { + t.Fatalf("model = %q, want gpt-5.5 written to the OpenAI row", cfg.Providers[0].Model) + } + }) + + t.Run("switchProviderModel", func(t *testing.T) { + configPath := newConfig(t) + saved := []config.ProviderProfile{ + {Name: "OpenAI", CatalogID: "openai", Model: "gpt-5.1", APIKey: "sk-test"}, + {Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "m1"}, + } + m := newModel(context.Background(), Options{ + UserConfigPath: configPath, + ProviderName: "ollama", + ModelName: "m1", + Provider: &fakeProvider{}, + ProviderProfile: saved[1], + SavedProviders: saved, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return &fakeProvider{}, nil + }, + }) + + // "openai" is the picker row's owner spelling, not the persisted one. + if _, status, ok, _ := m.switchProviderModel("openai", "gpt-5.5"); !ok { + t.Fatalf("switch to a case-variant provider spelling failed: %s", status) + } + cfg := readTUIConfigFixture(t, configPath) + if cfg.ActiveProvider != "OpenAI" { + t.Fatalf("activeProvider = %q, want the row's spelling OpenAI", cfg.ActiveProvider) + } + if cfg.Providers[0].Model != "gpt-5.5" { + t.Fatalf("model = %q, want gpt-5.5 persisted onto the OpenAI row", cfg.Providers[0].Model) + } + }) +} diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 563dffe62..64554b2c9 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -282,7 +282,7 @@ func (m model) modelPickerProviders() []config.ProviderProfile { // active provider prefers its live-discovered models when available. func (m model) savedProviderModelPickerItems(profile config.ProviderProfile, activeProvider, activeModel string) []pickerItem { providerName := strings.TrimSpace(profile.Name) - isActive := providerName != "" && strings.EqualFold(providerName, activeProvider) + isActive := providerName != "" && config.SameProviderIdentity(providerName, activeProvider) descriptor, hasDescriptor := m.descriptorForProfile(profile) group := modelPickerProviderGroup(profile, descriptor, hasDescriptor) diff --git a/internal/tui/provider_manager.go b/internal/tui/provider_manager.go index 9bd80dc7f..03d073bc1 100644 --- a/internal/tui/provider_manager.go +++ b/internal/tui/provider_manager.go @@ -281,9 +281,18 @@ func (m model) handleProviderManageListKey(msg tea.KeyMsg) (model, tea.Cmd) { } return m, nil case strings.EqualFold(keyText(msg), "d"): - if _, ok := wizard.currentManagerRow(); ok { + if row, ok := wizard.currentManagerRow(); ok { wizard.manageDeleting = true wizard.manageStatus = "" + // Resolve the retention outcome now, from the same predicate the + // delete uses, so the confirmation cannot promise a key removal the + // delete will not perform. + wizard.manageDeleteKeepsKey = false + if path := strings.TrimSpace(m.userConfigPath); path != "" { + if retained, err := config.ProviderKeyRetainedAfterRemoval(path, row.profile.Name); err == nil { + wizard.manageDeleteKeepsKey = retained + } + } } return m, nil } @@ -357,17 +366,24 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { var activeAfter string var cleanup tea.Cmd if persisted { - cfg, err := config.RemoveProvider(m.userConfigPath, name) + // The manager row carries a RESOLVED name, which may not be the persisted + // row's own spelling; RemoveProvider targets rows exactly. Bridge first. + exactName, err := config.ResolvePersistedProviderName(m.userConfigPath, name) + if err != nil { + wizard.manageStatus = "Delete failed: " + err.Error() + return m, nil + } + cfg, err := config.RemoveProvider(m.userConfigPath, exactName) if err != nil { wizard.manageStatus = "Delete failed: " + err.Error() return m, nil } activeAfter = cfg.ActiveProvider - deleteStoredKey := !providerIdentitySurvives(cfg.Providers, name) + deleteStoredKey := !config.CredentialKeyRetained(cfg.Providers, exactName) if deleteStoredKey { notes = []string{"Deleted " + name + ". Its stored API key will also be deleted."} } else { - notes = []string{"Deleted " + name + ". Kept its stored API key because another provider uses the same credential identity."} + notes = []string{"Deleted " + name + ". Kept its stored API key because another saved provider still uses that credential."} } cleanup = providerManagerCleanupCmd(m.userConfigPath, row.profile, deleteStoredKey) } else { @@ -411,15 +427,6 @@ func removeSavedProvider(saved []config.ProviderProfile, name string) []config.P return kept } -func providerIdentitySurvives(providers []config.ProviderProfile, removedName string) bool { - for _, provider := range providers { - if config.SameProviderIdentity(provider.Name, removedName) { - return true - } - } - return false -} - func samePersistedProviderName(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) } @@ -619,13 +626,21 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { wizard.err = "provider " + oldName + " is not saved in config.json, so there is no saved profile to edit" return m, nil } + // The edited row came from the resolved list, whose spelling can differ from + // the persisted row's; EditProvider matches rows exactly. Bridge before both + // the credential capture and the write so they target the same row. + exactName, err := config.ResolvePersistedProviderName(m.userConfigPath, oldName) + if err != nil { + wizard.err = err.Error() + return m, nil + } newName := strings.TrimSpace(wizard.editDraft.Name) if newName == "" { wizard.err = "name cannot be empty" return m, nil } edit := config.ProviderEdit{ - Name: oldName, + Name: exactName, NewName: newName, BaseURL: strings.TrimSpace(wizard.editDraft.BaseURL), Model: strings.TrimSpace(wizard.editDraft.Model), @@ -636,7 +651,7 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { wizard.err = err.Error() return m, nil } - captured := config.SecureProviderProfile(config.ProviderProfile{Name: oldName, APIKey: key}, m.userConfigPath) + captured := config.SecureProviderProfile(config.ProviderProfile{Name: exactName, APIKey: key}, m.userConfigPath) // On a store failure SecureProviderProfile keeps the inline key, which // EditProvider then persists (the startup migration re-captures later) — // the same fail-soft posture as every other capture path. @@ -777,7 +792,11 @@ func (wizard *providerWizardState) renderManageStep(width int) []string { } lines = append(lines, fitStyledLine(zeroTheme.faint.Render(detail), width)) if wizard.manageDeleting { - lines = append(lines, fitStyledLine(zeroTheme.red.Render("Delete "+row.profile.Name+"? This also removes its stored API key. Enter/y confirm · Esc/n cancel"), width)) + keyNote := "This also removes its stored API key." + if wizard.manageDeleteKeepsKey { + keyNote = "Its stored API key is kept — another saved provider shares that credential." + } + lines = append(lines, fitStyledLine(zeroTheme.red.Render("Delete "+row.profile.Name+"? "+keyNote+" Enter/y confirm · Esc/n cancel"), width)) } } return lines diff --git a/internal/tui/provider_manager_test.go b/internal/tui/provider_manager_test.go index b68e65ca6..6a438520f 100644 --- a/internal/tui/provider_manager_test.go +++ b/internal/tui/provider_manager_test.go @@ -793,3 +793,104 @@ func TestProviderManagerCaseVariantEditDoesNotChangeLiveSibling(t *testing.T) { t.Fatalf("wrong persisted edit target: %+v", cfg.Providers) } } + +// The confirmation prompt must promise what the delete actually does: with a +// case variant that still claims the shared credential, the key is kept, so +// the prompt must not say it is about to be removed. +func TestProviderManagerDeleteConfirmMatchesKeyRetentionPolicy(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + + newManagerAtRow := func(t *testing.T, configJSON string, profiles []config.ProviderProfile, cursor int) model { + t.Helper() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + ProviderName: profiles[0].Name, + ProviderProfile: profiles[0], + SavedProviders: profiles, + UserConfigPath: configPath, + }) + m, _ = m.openProviderManager() + m.providerWizard.manageCursor = cursor + next, _ := m.handleProviderWizardKey(testKeyText("d")) + if !next.providerWizard.manageDeleting { + t.Fatal("d must arm the delete confirm") + } + return next + } + + t.Run("shared credential is kept", func(t *testing.T) { + m := newManagerAtRow(t, + `{"activeProvider":"work","providers":[{"name":"work","apiKeyStored":true},{"name":"WORK","apiKeyStored":true}]}`, + []config.ProviderProfile{{Name: "work", APIKeyStored: true}, {Name: "WORK", APIKeyStored: true}}, + 1, + ) + if !m.providerWizard.manageDeleteKeepsKey { + t.Fatal("retention not resolved for a survivor that claims the credential") + } + view := strings.Join(m.providerWizard.renderManageStep(80), "\n") + if !strings.Contains(view, "stored API key is kept") { + t.Fatalf("confirm text = %q, want the key-kept wording", view) + } + }) + + t.Run("last owner removal deletes the key", func(t *testing.T) { + m := newManagerAtRow(t, + `{"activeProvider":"work","providers":[{"name":"work","apiKeyStored":true},{"name":"other"}]}`, + []config.ProviderProfile{{Name: "work", APIKeyStored: true}, {Name: "other"}}, + 0, + ) + if m.providerWizard.manageDeleteKeepsKey { + t.Fatal("retention must be false when no survivor claims the credential") + } + view := strings.Join(m.providerWizard.renderManageStep(80), "\n") + if !strings.Contains(view, "also removes its stored API key") { + t.Fatalf("confirm text = %q, want the key-removal wording", view) + } + }) +} + +// A markerless case variant does not own the shared credential, so removing +// the only row that claimed it must delete the secret rather than orphan it +// behind a profile ApplyStoredAPIKey will never read. +func TestProviderManagerRemoveDeletesKeyWhenSurvivorNeverClaimedIt(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + profiles := []config.ProviderProfile{ + {Name: "work", APIKeyStored: true}, + {Name: "WORK"}, + } + if err := os.WriteFile(configPath, []byte(`{"activeProvider":"work","providers":[{"name":"work","apiKeyStored":true},{"name":"WORK"}]}`), 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "sk-shared"); err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + ProviderName: "work", + ProviderProfile: profiles[0], + SavedProviders: profiles, + UserConfigPath: configPath, + }) + m, _ = m.openProviderManager() + m.providerWizard.manageCursor = 0 + next, cmd := m.deleteManagerSelection() + next = drainProviderManagerCmds(t, next, cmd) + + if _, ok, getErr := store.Get("WORK"); getErr != nil { + t.Fatal(getErr) + } else if ok { + t.Fatal("shared key was orphaned behind a markerless survivor") + } + if status := next.providerWizard.manageStatus; !strings.Contains(status, "stored API key will also be deleted") { + t.Fatalf("delete status = %q, want the key-deletion note", status) + } +} diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index c65026592..abf3029ae 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -210,8 +210,10 @@ func appendOAuthLoginProfile(saved []config.ProviderProfile, providerID string) return saved } for _, profile := range saved { - if strings.EqualFold(strings.TrimSpace(profile.CatalogID), descriptor.ID) || - strings.EqualFold(strings.TrimSpace(profile.Name), descriptor.ID) { + // "Does this profile already serve the catalog entry?" is a provider + // identity question — same rule as everywhere else, not EqualFold. + if config.SameProviderIdentity(profile.CatalogID, descriptor.ID) || + config.SameProviderIdentity(profile.Name, descriptor.ID) { return saved } } @@ -443,13 +445,17 @@ type providerWizardState struct { // the wizard is on providerWizardStepAimlapi. aimlapi *aimlapiOnboardState // Manager state (provider_manager.go): the list-first /provider surface. - manage bool - manageRows []providerManagerRow - manageCursor int - manageDeleting bool - manageStatus string - manageCredGen int - manageActiveName string + manage bool + manageRows []providerManagerRow + manageCursor int + manageDeleting bool + // manageDeleteKeepsKey is resolved when the delete confirmation opens, from + // config.ProviderKeyRetainedAfterRemoval, so the prompt and the delete agree + // about whether the stored key survives. + manageDeleteKeepsKey bool + manageStatus string + manageCredGen int + manageActiveName string // Edit state: field-level editor for one saved profile. editOriginal config.ProviderProfile editDraft config.ProviderProfile @@ -1302,20 +1308,46 @@ func (m model) applyProviderWizard() (model, tea.Cmd) { // wizardProviderStoredKey reports the saved provider name that has a key in the // credential store matching the wizard-selected descriptor, so the wizard can offer // keep/replace/remove instead of forcing a new key entry. +// +// The name comparisons ask a credential question — "does this profile's store +// entry serve the descriptor?" — so they use the store's own normalization +// rather than strings.EqualFold, which folds "s" and Unicode long-s "ſ" into +// one identity the store keeps apart. func (m model) wizardProviderStoredKey(provider providercatalog.Descriptor) (string, bool) { for _, profile := range m.savedProviders { if !profile.APIKeyStored { continue } - if strings.EqualFold(strings.TrimSpace(profile.Name), strings.TrimSpace(provider.Name)) || - strings.EqualFold(strings.TrimSpace(profile.CatalogID), strings.TrimSpace(provider.ID)) || - strings.EqualFold(strings.TrimSpace(profile.Name), strings.TrimSpace(provider.ID)) { + if config.SameProviderIdentity(profile.Name, provider.Name) || + config.SameProviderIdentity(profile.CatalogID, provider.ID) || + config.SameProviderIdentity(profile.Name, provider.ID) { return profile.Name, true } } return "", false } +// applyProviderKeyRemovalToSession mirrors a stored-key removal into the live +// session: every in-memory profile that shares the removed credential identity +// drops its APIKeyStored marker, matching what +// ClearProviderKeyStoredCaseVariants just wrote to disk. +func (m model) applyProviderKeyRemovalToSession(name string) model { + // Copy before mutating: model is passed by value, but the slice header is + // shared, so an in-place write would reach every other copy of the model. + updated := make([]config.ProviderProfile, len(m.savedProviders)) + copy(updated, m.savedProviders) + for index := range updated { + if config.SameProviderIdentity(updated[index].Name, name) { + updated[index].APIKeyStored = false + } + } + m.savedProviders = updated + if config.SameProviderIdentity(m.providerProfile.Name, name) { + m.providerProfile.APIKeyStored = false + } + return m +} + // applyManageKeyChoice acts on the keep/replace/remove selection. Keep closes the // wizard (nothing changes); Replace routes to credential entry (overwrites on save); // Remove deletes the stored key and its marker. @@ -1338,16 +1370,26 @@ func (m model) applyManageKeyChoice() (model, tea.Cmd) { return m, nil } } - if _, err := m.deleteProviderKey(m.userConfigPath, name); err != nil { - wizard.err = "Stored key removal failed: " + redaction.ErrorMessage(err, redaction.Options{}) - return m, nil - } + // Marker first, secret second. The reverse order (which logout already + // fixed) leaves apiKeyStored:true with no secret behind it if the marker + // write fails — a profile that claims a credential every lookup misses. + // Clearing first can at worst orphan a secret no profile reads. if strings.TrimSpace(m.userConfigPath) != "" { if _, err := m.clearProviderKeyStored(m.userConfigPath, name); err != nil { wizard.err = "Stored key marker cleanup failed: " + redaction.ErrorMessage(err, redaction.Options{}) return m, nil } } + if _, err := m.deleteProviderKey(m.userConfigPath, name); err != nil { + wizard.err = "Stored key removal failed: " + redaction.ErrorMessage(err, redaction.Options{}) + + " — the saved-key marker was already cleared, so no profile claims it, but the secret may still be in the credential store." + return m, nil + } + // Reconcile the live session with the disk write: savedProviders and + // providerProfile still carry APIKeyStored:true otherwise, so /providers + // and a re-entered wizard would offer keep/replace for a key that is gone + // until the next restart. + m = m.applyProviderKeyRemovalToSession(name) m.providerWizard = nil m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Provider\nRemoved the stored key for " + name + ". Re-add it any time with /provider."}) return m, nil diff --git a/internal/tui/provider_wizard_discovery.go b/internal/tui/provider_wizard_discovery.go index b0dcada69..1ff8a51ee 100644 --- a/internal/tui/provider_wizard_discovery.go +++ b/internal/tui/provider_wizard_discovery.go @@ -99,7 +99,8 @@ func (m model) existingAimlapiConfiguration() (config.ProviderProfile, string, b activeName := strings.TrimSpace(m.providerProfile.Name) if activeName != "" { for index, profile := range profiles { - if strings.EqualFold(strings.TrimSpace(profile.Name), activeName) && aimlapiProfile(profile) { + // Selecting the live session's own row: exact persisted spelling. + if strings.TrimSpace(profile.Name) == activeName && aimlapiProfile(profile) { profiles[0], profiles[index] = profiles[index], profiles[0] break } diff --git a/internal/tui/provider_wizard_test.go b/internal/tui/provider_wizard_test.go index ed925d11d..b0bd7dfe8 100644 --- a/internal/tui/provider_wizard_test.go +++ b/internal/tui/provider_wizard_test.go @@ -1244,8 +1244,10 @@ func TestProviderWizardManageKeyRemoveReportsCleanupFailures(t *testing.T) { if next.providerWizard == nil || !strings.Contains(next.providerWizard.err, "Stored key removal failed") { t.Fatalf("wizard did not remain open with deletion error: %+v", next.providerWizard) } - if cfg := readProviderWizardConfigFixture(t, next.userConfigPath); !cfg.Providers[0].APIKeyStored { - t.Fatal("deletion failure cleared the persisted marker") + // Marker first, secret second: a failed delete leaves an orphaned secret + // that nothing reads, never a marker claiming a key that is gone. + if cfg := readProviderWizardConfigFixture(t, next.userConfigPath); cfg.Providers[0].APIKeyStored { + t.Fatal("marker must be cleared before the secret delete is attempted") } }) @@ -1930,3 +1932,59 @@ func TestProviderWizardModelRowsStayDistinctWithProseDescriptions(t *testing.T) t.Errorf("prose blurb still used as a row label:\n%s", view) } } + +// The disk write is only half the removal: the live session still holds +// APIKeyStored:true until it is reconciled, so /providers and a re-entered +// wizard would keep offering keep/replace for a key that is gone. +func TestProviderWizardManageKeyRemoveSyncsSessionState(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":[{"name":"work","apiKeyStored":true}]}`), 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "sk-secret"); err != nil { + t.Fatal(err) + } + profile := config.ProviderProfile{Name: "work", APIKeyStored: true} + m := newModel(context.Background(), Options{ + UserConfigPath: configPath, + ProviderName: "work", + ProviderProfile: profile, + SavedProviders: []config.ProviderProfile{profile}, + }) + m.providerWizard = &providerWizardState{step: providerWizardStepManageKey, manageProviderName: "WORK", manageKeyCursor: 2} + + next, _ := m.applyManageKeyChoice() + + if next.providerProfile.APIKeyStored { + t.Fatal("live profile still claims a stored key after removal") + } + if len(next.savedProviders) != 1 || next.savedProviders[0].APIKeyStored { + t.Fatalf("saved providers not reconciled: %+v", next.savedProviders) + } + // The caller's copy must be untouched: savedProviders is mutated by copy. + if !m.savedProviders[0].APIKeyStored { + t.Fatal("session sync mutated the pre-removal model's slice in place") + } +} + +// wizardProviderStoredKey answers a credential question, so it must use the +// store's normalization: strings.EqualFold folds "s" and Unicode long-s into +// one identity that the store keeps apart. +func TestWizardProviderStoredKeyDistinguishesUnicodeIdentities(t *testing.T) { + m := model{savedProviders: []config.ProviderProfile{ + {Name: "ſ", APIKeyStored: true}, + }} + if name, ok := m.wizardProviderStoredKey(providercatalog.Descriptor{Name: "s", ID: "s"}); ok { + t.Fatalf("latin-s descriptor matched the long-s profile %q", name) + } + name, ok := m.wizardProviderStoredKey(providercatalog.Descriptor{Name: "ſ", ID: "ſ"}) + if !ok || name != "ſ" { + t.Fatalf("long-s descriptor = %q, %v; want its own profile", name, ok) + } +} diff --git a/internal/tui/session.go b/internal/tui/session.go index 4aec00e58..b978f9ac9 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/execution" "github.com/Gitlawb/zero/internal/sandbox" "github.com/Gitlawb/zero/internal/sessions" @@ -333,7 +334,10 @@ func (m model) formatResumeSummary(session sessions.Metadata, eventCount int) st modelLine += " (recorded: " + recorded + ")" } providerLine := "provider: " + displayValue(m.providerName, "none") - if recorded := strings.TrimSpace(session.Provider); recorded != "" && !strings.EqualFold(recorded, m.providerName) { + // Provider names are compared with the credential store's rule, so a + // recorded spelling that is a genuinely different provider (Unicode long-s) + // is reported as a difference rather than folded into a silent match. + if recorded := strings.TrimSpace(session.Provider); recorded != "" && !config.SameProviderIdentity(recorded, m.providerName) { providerLine += " (recorded: " + recorded + ")" } lines := []string{ From a3b18f5afc83c4f8967e1b270865792774de6124 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 16 Aug 2026 14:48:40 +0200 Subject: [PATCH 08/17] fix(provider): address CodeRabbit review on the identity contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PublishProviderCredential joins a failed rollback into the returned error. A store left holding a key the config does not describe is the state the caller most needs to hear about, and it must never surface as a plain publication failure. The key value stays out of the message. - ProviderKeyRetainedAfterRemoval resolves the provider name the same way the delete does. Previewing against an unresolved case variant removed nothing, so a "key is kept" preview could precede a delete that resolves the row and takes the key with it. - The manager delete confirmation carries a note string rather than a bool, so a row with nothing to claim — env-derived, no config path, or a config too ambiguous for the delete to proceed — makes no promise about the stored key instead of asserting a removal that cannot happen. - Redact credential values from the test failure messages added in this branch: report presence and length instead of the key, and named fields instead of a whole ProviderProfile. Tests: persistence-failure coverage for switchProviderModel (unreadable config, unresolvable row, and the env-derived case that must stay silent), and confirmation-note coverage for the unresolvable and case-variant rows. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Pierre Bruno --- internal/cli/auth_test.go | 2 +- internal/cli/provider_identity_matrix_test.go | 20 +++--- internal/config/credentials.go | 12 +++- internal/config/credentials_test.go | 6 +- internal/config/writer.go | 9 ++- internal/tui/command_center_test.go | 69 +++++++++++++++++++ internal/tui/provider_manager.go | 35 +++++++--- internal/tui/provider_manager_test.go | 62 ++++++++++++++++- internal/tui/provider_wizard.go | 12 ++-- 9 files changed, 191 insertions(+), 36 deletions(-) diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 1c1dd3ead..7a312bba1 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -459,7 +459,7 @@ func TestRunAuthOpenRouterPreservesExistingKeyWhenConfigRejected(t *testing.T) { t.Fatal(err) } if !ok || key != "sk-working" { - t.Fatalf("stored key = %q (present=%v), want the previous sk-working preserved", key, ok) + t.Fatalf("stored key does not match (present=%v, len=%d), want the previous sk-working preserved", ok, len(key)) } // The minted key is still handed over for manual use. if !strings.Contains(stdout.String(), "sk-minted") { diff --git a/internal/cli/provider_identity_matrix_test.go b/internal/cli/provider_identity_matrix_test.go index 13536d60b..a05f2e0f8 100644 --- a/internal/cli/provider_identity_matrix_test.go +++ b/internal/cli/provider_identity_matrix_test.go @@ -80,8 +80,8 @@ func TestProviderIdentityMatrix(t *testing.T) { if cfg := readFileConfig(t, configPath); len(cfg.Providers) != 0 { t.Fatalf("providers = %#v, want the row removed", cfg.Providers) } - if key, ok := storedProviderKey(t, configPath, "work"); ok { - t.Fatalf("stored key %q survived removal of its only owner", key) + if _, ok := storedProviderKey(t, configPath, "work"); ok { + t.Fatal("stored key survived removal of its only owner") } }) @@ -123,7 +123,7 @@ func TestProviderIdentityMatrix(t *testing.T) { t.Fatalf("rejected removal rewrote config:\n%s", after) } if key, ok := storedProviderKey(t, configPath, "work"); !ok || key != "sk-work" { - t.Fatalf("stored key = %q (present=%v), want sk-work untouched", key, ok) + t.Fatalf("stored key does not match (present=%v, len=%d), want sk-work untouched", ok, len(key)) } }) @@ -143,7 +143,7 @@ func TestProviderIdentityMatrix(t *testing.T) { } key, ok := storedProviderKey(t, configPath, "WORK") if !ok || key != "sk-shared" { - t.Fatalf("stored key = %q (present=%v), want the survivor's sk-shared kept", key, ok) + t.Fatalf("stored key does not match (present=%v, len=%d), want the survivor's sk-shared kept", ok, len(key)) } // The survivor must actually be able to load it. store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) @@ -151,7 +151,7 @@ func TestProviderIdentityMatrix(t *testing.T) { t.Fatal(err) } if loaded := config.ApplyStoredAPIKey(cfg.Providers[0], store); strings.TrimSpace(loaded.APIKey) != "sk-shared" { - t.Fatalf("survivor loaded APIKey = %q, want sk-shared", loaded.APIKey) + t.Fatalf("survivor did not load the retained key (len=%d), want sk-shared", len(strings.TrimSpace(loaded.APIKey))) } }) @@ -167,8 +167,8 @@ func TestProviderIdentityMatrix(t *testing.T) { } // The surviving WORK row never claimed the credential, so keeping the // secret would only orphan it behind a marker ApplyStoredAPIKey skips. - if key, ok := storedProviderKey(t, configPath, "WORK"); ok { - t.Fatalf("stored key %q was orphaned behind a markerless survivor", key) + if _, ok := storedProviderKey(t, configPath, "WORK"); ok { + t.Fatal("stored key was orphaned behind a markerless survivor") } if !strings.Contains(stdout.String(), "Deleted its stored API key.") { t.Fatalf("stdout = %q, want the key-deletion note", stdout.String()) @@ -225,10 +225,10 @@ func TestProviderIdentityMatrix(t *testing.T) { t.Fatalf("config = %#v, want only the long-s row remaining", cfg) } if key, ok := storedProviderKey(t, configPath, "ſ"); !ok || key != "sk-long" { - t.Fatalf("long-s key = %q (present=%v), want sk-long untouched", key, ok) + t.Fatalf("long-s key does not match (present=%v, len=%d), want sk-long untouched", ok, len(key)) } - if key, ok := storedProviderKey(t, configPath, "s"); ok { - t.Fatalf("latin-s key %q survived removal of its only owner", key) + if _, ok := storedProviderKey(t, configPath, "s"); ok { + t.Fatal("latin-s key survived removal of its only owner") } }) } diff --git a/internal/config/credentials.go b/internal/config/credentials.go index fc3b3f427..5ca3ac555 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -109,10 +109,18 @@ func PublishProviderCredential(path string, exactName string, key string) error if err := MarkProviderAPIKeyStored(path, exactName); err != nil { // Put the store back exactly as it was: restore a prior key rather than // deleting it, and only delete when this call created the entry. + var rollbackErr error if hadPrevious { - _ = store.Set(exactName, previous) + rollbackErr = store.Set(exactName, previous) } else { - _, _ = store.Delete(exactName) + _, rollbackErr = store.Delete(exactName) + } + if rollbackErr != nil { + // A failed rollback is the state the caller most needs to hear + // about: the store now holds a key the config does not describe. + // Never let it be reported as a plain publication failure. The key + // value itself stays out of the message. + return fmt.Errorf("%w (credential store rollback also failed: %v)", err, rollbackErr) } return err } diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go index 38c5bb250..0841bf8cd 100644 --- a/internal/config/credentials_test.go +++ b/internal/config/credentials_test.go @@ -402,7 +402,7 @@ func TestPublishProviderCredentialRestoresPreviousKeyWhenMarkerRejected(t *testi t.Fatal(err) } if !ok || key != "sk-working" { - t.Fatalf("stored key = %q (present=%v), want the previous sk-working restored", key, ok) + t.Fatalf("stored key does not match the previous value (present=%v, len=%d), want sk-working restored", ok, len(key)) } after, err := os.ReadFile(path) if err != nil { @@ -452,7 +452,7 @@ func TestPublishProviderCredentialStoresAndMarks(t *testing.T) { } key, ok, err := store.Get("openrouter") if err != nil || !ok || key != "sk-new" { - t.Fatalf("stored key = %q (present=%v, err=%v), want sk-new", key, ok, err) + t.Fatalf("stored key does not match (present=%v, len=%d, err=%v), want sk-new", ok, len(key), err) } var cfg FileConfig data, err := os.ReadFile(path) @@ -463,6 +463,6 @@ func TestPublishProviderCredentialStoresAndMarks(t *testing.T) { t.Fatal(err) } if !cfg.Providers[0].APIKeyStored || strings.TrimSpace(cfg.Providers[0].APIKeyEnv) != "" { - t.Fatalf("marker not published: %+v", cfg.Providers[0]) + t.Fatalf("marker not published: apiKeyStored=%v apiKeyEnv=%q", cfg.Providers[0].APIKeyStored, cfg.Providers[0].APIKeyEnv) } } diff --git a/internal/config/writer.go b/internal/config/writer.go index 0604fa417..66cae3b59 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -189,7 +189,14 @@ func ProviderKeyRetainedAfterRemoval(path string, name string) (bool, error) { if err != nil { return false, err } - name = strings.TrimSpace(name) + // Resolve first, for the same reason the delete does: callers hand this a + // spelling from a resolved list, which may not be the row's own. Previewing + // against an unresolved name removes nothing, so a "key is kept" preview + // could precede a delete that resolves the row and takes the key with it. + name, err = resolvePersistedProviderName(providers, name) + if err != nil { + return false, err + } remaining := make([]ProviderProfile, 0, len(providers)) removed := false for _, provider := range providers { diff --git a/internal/tui/command_center_test.go b/internal/tui/command_center_test.go index 161b7e8b7..8dd61cf79 100644 --- a/internal/tui/command_center_test.go +++ b/internal/tui/command_center_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/Gitlawb/zero/internal/config" @@ -73,3 +74,71 @@ func TestModelPersistenceUsesResolvedPersistedSpelling(t *testing.T) { } }) } + +// The switch deliberately continues in-session when config.json cannot be +// updated, so the note is the only thing telling the user the two now disagree. +// Silence here would read as a saved switch that was never persisted. +func TestSwitchProviderModelReportsPersistenceFailures(t *testing.T) { + saved := []config.ProviderProfile{ + {Name: "OpenAI", CatalogID: "openai", Model: "gpt-5.1", APIKey: "sk-test"}, + {Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "m1"}, + } + newSwitchModel := func(t *testing.T, configJSON string) model { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(configJSON), 0o600); err != nil { + t.Fatal(err) + } + return newModel(context.Background(), Options{ + UserConfigPath: path, + ProviderName: "ollama", + ModelName: "m1", + Provider: &fakeProvider{}, + ProviderProfile: saved[1], + SavedProviders: saved, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return &fakeProvider{}, nil + }, + }) + } + + t.Run("unreadable config", func(t *testing.T) { + m := newSwitchModel(t, `{"providers":[`) // invalid JSON + next, status, ok, _ := m.switchProviderModel("OpenAI", "gpt-5.5") + if !ok { + t.Fatalf("the in-session switch must still succeed: %s", status) + } + if !strings.Contains(status, "config.json could not be read") { + t.Fatalf("status = %q, want a persistence note", status) + } + // The session did switch, which is exactly why the note has to be there. + if next.providerName != "OpenAI" { + t.Fatalf("providerName = %q, want OpenAI", next.providerName) + } + }) + + t.Run("ambiguous rows block the write", func(t *testing.T) { + // Duplicate case variants pass the persisted gate but make the write + // itself unresolvable. + m := newSwitchModel(t, `{"providers":[{"name":"OpenAI"},{"name":"openai"}]}`) + _, status, ok, _ := m.switchProviderModel("OpenAI", "gpt-5.5") + if !ok { + t.Fatalf("the in-session switch must still succeed: %s", status) + } + if !strings.Contains(status, "config.json was not updated") { + t.Fatalf("status = %q, want the active-provider persistence note", status) + } + }) + + t.Run("env-derived provider stays silent", func(t *testing.T) { + // No row to update is not a failure, so it must not produce a note. + m := newSwitchModel(t, `{"providers":[{"name":"ollama","model":"m1"}]}`) + _, status, ok, _ := m.switchProviderModel("OpenAI", "gpt-5.5") + if !ok { + t.Fatalf("switch failed: %s", status) + } + if strings.Contains(status, "Note:") { + t.Fatalf("status = %q, want no note for a provider with no persisted row", status) + } + }) +} diff --git a/internal/tui/provider_manager.go b/internal/tui/provider_manager.go index 03d073bc1..80e3b3ff6 100644 --- a/internal/tui/provider_manager.go +++ b/internal/tui/provider_manager.go @@ -287,12 +287,7 @@ func (m model) handleProviderManageListKey(msg tea.KeyMsg) (model, tea.Cmd) { // Resolve the retention outcome now, from the same predicate the // delete uses, so the confirmation cannot promise a key removal the // delete will not perform. - wizard.manageDeleteKeepsKey = false - if path := strings.TrimSpace(m.userConfigPath); path != "" { - if retained, err := config.ProviderKeyRetainedAfterRemoval(path, row.profile.Name); err == nil { - wizard.manageDeleteKeepsKey = retained - } - } + wizard.manageDeleteKeyNote = providerDeleteKeyNote(m.userConfigPath, row.profile.Name) } return m, nil } @@ -427,6 +422,26 @@ func removeSavedProvider(saved []config.ProviderProfile, name string) []config.P return kept } +// providerDeleteKeyNote is the delete confirmation's sentence about the stored +// key, computed from the same helpers the delete itself uses so the prompt can +// never promise an outcome the delete will not produce. It returns "" — no +// claim at all — for a row with nothing to say: an env-derived provider with no +// persisted row, no user config path, or a config whose ambiguity will make the +// delete fail before it touches anything. +func providerDeleteKeyNote(configPath string, name string) string { + if strings.TrimSpace(configPath) == "" { + return "" + } + retained, err := config.ProviderKeyRetainedAfterRemoval(configPath, name) + if err != nil { + return "" + } + if retained { + return "Its stored API key is kept — another saved provider still uses that credential." + } + return "This also removes its stored API key." +} + func samePersistedProviderName(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) } @@ -792,11 +807,11 @@ func (wizard *providerWizardState) renderManageStep(width int) []string { } lines = append(lines, fitStyledLine(zeroTheme.faint.Render(detail), width)) if wizard.manageDeleting { - keyNote := "This also removes its stored API key." - if wizard.manageDeleteKeepsKey { - keyNote = "Its stored API key is kept — another saved provider shares that credential." + prompt := "Delete " + row.profile.Name + "?" + if note := strings.TrimSpace(wizard.manageDeleteKeyNote); note != "" { + prompt += " " + note } - lines = append(lines, fitStyledLine(zeroTheme.red.Render("Delete "+row.profile.Name+"? "+keyNote+" Enter/y confirm · Esc/n cancel"), width)) + lines = append(lines, fitStyledLine(zeroTheme.red.Render(prompt+" Enter/y confirm · Esc/n cancel"), width)) } } return lines diff --git a/internal/tui/provider_manager_test.go b/internal/tui/provider_manager_test.go index 6a438520f..342fc8858 100644 --- a/internal/tui/provider_manager_test.go +++ b/internal/tui/provider_manager_test.go @@ -828,7 +828,7 @@ func TestProviderManagerDeleteConfirmMatchesKeyRetentionPolicy(t *testing.T) { []config.ProviderProfile{{Name: "work", APIKeyStored: true}, {Name: "WORK", APIKeyStored: true}}, 1, ) - if !m.providerWizard.manageDeleteKeepsKey { + if m.providerWizard.manageDeleteKeyNote == "" { t.Fatal("retention not resolved for a survivor that claims the credential") } view := strings.Join(m.providerWizard.renderManageStep(80), "\n") @@ -843,8 +843,8 @@ func TestProviderManagerDeleteConfirmMatchesKeyRetentionPolicy(t *testing.T) { []config.ProviderProfile{{Name: "work", APIKeyStored: true}, {Name: "other"}}, 0, ) - if m.providerWizard.manageDeleteKeepsKey { - t.Fatal("retention must be false when no survivor claims the credential") + if m.providerWizard.manageDeleteKeyNote == "" { + t.Fatal("delete confirmation made no claim about a persisted row's key") } view := strings.Join(m.providerWizard.renderManageStep(80), "\n") if !strings.Contains(view, "also removes its stored API key") { @@ -894,3 +894,59 @@ func TestProviderManagerRemoveDeletesKeyWhenSurvivorNeverClaimedIt(t *testing.T) t.Fatalf("delete status = %q, want the key-deletion note", status) } } + +// A row visible only because Resolve() synthesized it from an env var has no +// persisted profile and no stored key, so the confirmation must make no claim +// about a key rather than promising a removal that cannot happen. The same +// holds when the config is too ambiguous for the delete to proceed at all. +func TestProviderDeleteKeyNoteMakesNoClaimWithoutAResolvableRow(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + + cases := []struct { + name string + configJSON string + row string + }{ + { + name: "env-derived row with no persisted profile", + configJSON: `{"providers":[{"name":"other"}]}`, + row: "openai", + }, + { + name: "ambiguous duplicate rows the delete cannot resolve", + configJSON: `{"providers":[{"name":"work","apiKeyStored":true},{"name":"WORK","apiKeyStored":true}]}`, + row: "Work", + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(testCase.configJSON), 0o600); err != nil { + t.Fatal(err) + } + if note := providerDeleteKeyNote(path, testCase.row); note != "" { + t.Fatalf("note = %q, want no claim about the stored key", note) + } + }) + } + // No user config path at all: nothing can be promised either. + if note := providerDeleteKeyNote("", "work"); note != "" { + t.Fatalf("note = %q, want no claim without a config path", note) + } +} + +// The preview must resolve the row the same way the delete does: a case-variant +// spelling that removes nothing would preview "key kept" for a delete that +// resolves the row and takes the key with it. +func TestProviderDeleteKeyNoteResolvesCaseVariantSpelling(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"providers":[{"name":"WORK","apiKeyStored":true},{"name":"other"}]}`), 0o600); err != nil { + t.Fatal(err) + } + // "work" addresses the sole WORK row, whose removal takes the key with it. + note := providerDeleteKeyNote(path, "work") + if !strings.Contains(note, "also removes its stored API key") { + t.Fatalf("note = %q, want the key-removal wording for the resolved row", note) + } +} diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index abf3029ae..262e1723b 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -449,13 +449,13 @@ type providerWizardState struct { manageRows []providerManagerRow manageCursor int manageDeleting bool - // manageDeleteKeepsKey is resolved when the delete confirmation opens, from + // manageDeleteKeyNote is resolved when the delete confirmation opens, from // config.ProviderKeyRetainedAfterRemoval, so the prompt and the delete agree - // about whether the stored key survives. - manageDeleteKeepsKey bool - manageStatus string - manageCredGen int - manageActiveName string + // about what happens to the stored key. "" means make no claim. + manageDeleteKeyNote string + manageStatus string + manageCredGen int + manageActiveName string // Edit state: field-level editor for one saved profile. editOriginal config.ProviderProfile editDraft config.ProviderProfile From 55f6b48abe162327a2fc72745cab1ab966dcc2dd Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Mon, 17 Aug 2026 11:00:19 +0200 Subject: [PATCH 09/17] fix(tui): reconcile live session, saved list, and disk on one rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review tail on #892. Both code findings shared one root cause: live session state and the savedProviders mirror were updated by different rules at different call sites, with no single reconciliation policy after a mutation. Two predicates now own that, instead of spot fixes: - syncSavedProviderModel is the one place a persisted model change is mirrored into savedProviders. The manager's rows and the picker's model sections are built from that list, not from the live profile, so switchProviderModel and handleModelCommand both updated the client and config.json while /providers kept showing the previous model until restart. persistSelectedModel now returns the exact row it wrote so its caller mirrors onto that row rather than re-deriving it from the session's spelling. - sessionRowName answers "is this the provider I am running on?", a third question distinct from credential identity and from exact row-targeting. An exact spelling wins, so case-variant siblings and s/long-s stay distinct; only an identity carried by exactly one row resolves to that row's own spelling. That fixes a sole row the session spells differently (ZERO_PROVIDER=openai against a saved OpenAI) missing the active marker, the rename ZERO_PROVIDER sync, and the delete/edit notes. reloadProviderManagerRows resolves once so render and sync share one value. TestProviderManagerCaseVariantEditDoesNotChangeLiveSibling is split: its fixture held a single row, so it was the sole-row case rather than the sibling case its name claimed, and now asserts the sync. The real sibling guard moves to a two-row delete fixture — edit cannot exercise it because EditProvider rejects a duplicate-identity config first. Also documents scope rather than widening it: the ambiguous-config rejection now names `zero providers remove ` as the repair path, since that rejection blocks interactive startup for configs that worked before, and every fail-soft SecureProviderProfile capture site says that atomic capture+publish is #894. Co-Authored-By: Claude Opus 5 (1M context) Co-authored-by: Pierre Bruno --- internal/cli/provider_setup.go | 6 ++ internal/cli/setup.go | 2 + internal/config/writer.go | 7 +- internal/config/writer_test.go | 4 +- internal/tui/command_center.go | 33 +++++--- internal/tui/command_center_test.go | 75 ++++++++++++++++++- internal/tui/provider_manager.go | 104 +++++++++++++++++++++++--- internal/tui/provider_manager_test.go | 71 ++++++++++++++++-- internal/tui/provider_wizard.go | 2 + 9 files changed, 275 insertions(+), 29 deletions(-) diff --git a/internal/cli/provider_setup.go b/internal/cli/provider_setup.go index dc95bc1d9..71dbce40c 100644 --- a/internal/cli/provider_setup.go +++ b/internal/cli/provider_setup.go @@ -58,6 +58,12 @@ func runProvidersAdd(args []string, stdout io.Writer, stderr io.Writer, deps app } // Persist with the key moved into the encrypted credential store (capture flip); // the local profile keeps the key for the verification build below. + // + // Fail-soft capture: the preflight above rules out a validation failure + // AFTER the store write, but a config write that fails for another reason + // (permissions, disk full) still leaves a store entry with no apiKeyStored + // marker. Atomic capture+publish for this path is #894's transaction, not + // the OpenRouter-style PublishProviderCredential rollback this PR wires. cfg, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), options.setActive) if err != nil { return writeAppError(stderr, err.Error(), exitCrash) diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 4ee8c89c3..77025f9a5 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -269,6 +269,8 @@ func saveSetupProvider(deps appDeps, selection tui.SetupSelection, options setup } // Persist with the key moved into the encrypted credential store (capture flip); // the returned profile keeps the key for this run's immediate use. + // Fail-soft capture with no rollback on a failed config write — see the + // matching note in provider_setup.go; atomicity is #894. if _, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), true); err != nil { return tui.SetupResult{}, err } diff --git a/internal/config/writer.go b/internal/config/writer.go index 66cae3b59..49977290e 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -36,7 +36,12 @@ func ValidatePersistedProviderNames(cfg FileConfig) error { return fmt.Errorf("duplicate persisted provider name %q; remove one of the rows in config.json", name) } if ok { - return fmt.Errorf("ambiguous persisted provider names %q and %q differ only by case; rename or remove one row in config.json", previous, name) + // Name the repair command, not just the problem: this rejection is + // reached at config READ time, so a legacy duplicate blocks the + // interactive shell and the TUI outright. `zero providers remove` + // reads config.json directly instead of going through Resolve, so + // it still works while everything else refuses to start. + return fmt.Errorf("ambiguous persisted provider names %q and %q differ only by case; run `zero providers remove %s` (exact spelling) or rename one row in config.json", previous, name, name) } seen[folded] = name } diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go index a345872f6..e8a146e0e 100644 --- a/internal/config/writer_test.go +++ b/internal/config/writer_test.go @@ -1247,7 +1247,9 @@ func TestEditProviderRequiresExactProviderIdentityAmongCaseVariants(t *testing.T func assertAmbiguousConfigUnchanged(t *testing.T, path string, before []byte, err error, first, second string) { t.Helper() - want := fmt.Sprintf("ambiguous persisted provider names %q and %q differ only by case; rename or remove one row in config.json", first, second) + // The message must name the repair command: this rejection reaches the user + // at config read time, where it blocks interactive startup entirely. + want := fmt.Sprintf("ambiguous persisted provider names %q and %q differ only by case; run `zero providers remove %s` (exact spelling) or rename one row in config.json", first, second, second) if err == nil || err.Error() != want { t.Fatalf("error = %v, want %q", err, want) } diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index 3c44167fc..da5efef74 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -449,7 +449,12 @@ func (m model) handleModelCommand(args string) (model, string) { if err != nil { return m, "Model\n" + err.Error() } - persisted, persistErr := m.persistSelectedModel(nextProfile) + persisted, persistedName, persistErr := m.persistSelectedModel(nextProfile) + if persisted { + // Same reconciliation switchProviderModel does: the manager and picker + // read models from savedProviders, not from the live profile. + m.savedProviders = syncSavedProviderModel(m.savedProviders, persistedName, nextProfile.Model) + } m.providerProfile = nextProfile m.provider = nextProvider @@ -600,6 +605,10 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string, persistNote = "\nNote: the switch applies to this session, but config.json was not updated: " + redaction.RedactString(err.Error(), redaction.Options{}) } else if _, err := config.SetProviderModel(m.userConfigPath, cfg.ActiveProvider, target.Model); err != nil { persistNote = "\nNote: the active provider was saved, but its model was not: " + redaction.RedactString(err.Error(), redaction.Options{}) + } else { + // Reconcile the in-memory list the manager and picker read from, + // or those surfaces keep showing the previous model until restart. + m.savedProviders = syncSavedProviderModel(m.savedProviders, cfg.ActiveProvider, target.Model) } } } @@ -712,38 +721,42 @@ func (m model) savedProviderByName(name string) (config.ProviderProfile, bool) { return config.ProviderProfile{}, false } -func (m model) persistSelectedModel(profile config.ProviderProfile) (bool, error) { +// persistSelectedModel writes profile's model to its config.json row and +// returns the EXACT row spelling it wrote to, so the caller can mirror the same +// change into savedProviders with syncSavedProviderModel rather than re-deriving +// the row from the session's spelling. +func (m model) persistSelectedModel(profile config.ProviderProfile) (bool, string, error) { path := strings.TrimSpace(m.userConfigPath) if path == "" { - return false, nil + return false, "", nil } name := strings.TrimSpace(profile.Name) if name == "" { - return false, nil + return false, "", nil } model := strings.TrimSpace(profile.Model) if model == "" { - return false, nil + return false, "", nil } persisted, err := config.ProviderPersisted(path, name) if err != nil { - return false, err + return false, "", err } if !persisted { // Env-derived providers have no config.json row to update. - return false, nil + return false, "", nil } // ProviderPersisted matches credential identity; SetProviderModel matches // the row exactly. Resolve the session's spelling to the row's own before // writing, or a case difference makes this a silent no-op. exactName, err := config.ResolvePersistedProviderName(path, name) if err != nil { - return false, err + return false, "", err } if _, err := config.SetProviderModel(path, exactName, model); err != nil { - return false, err + return false, "", err } - return true, nil + return true, exactName, nil } type modelSwitchTarget struct { diff --git a/internal/tui/command_center_test.go b/internal/tui/command_center_test.go index 8dd61cf79..6839010a6 100644 --- a/internal/tui/command_center_test.go +++ b/internal/tui/command_center_test.go @@ -30,13 +30,18 @@ func TestModelPersistenceUsesResolvedPersistedSpelling(t *testing.T) { configPath := newConfig(t) m := newModel(context.Background(), Options{UserConfigPath: configPath}) // The session spelling "openai" addresses the persisted "OpenAI" row. - persisted, err := m.persistSelectedModel(config.ProviderProfile{Name: "openai", Model: "gpt-5.5"}) + persisted, persistedName, err := m.persistSelectedModel(config.ProviderProfile{Name: "openai", Model: "gpt-5.5"}) if err != nil { t.Fatal(err) } if !persisted { t.Fatal("persistSelectedModel reported no write for a persisted case variant") } + // The returned spelling is what the caller mirrors into savedProviders, + // so it has to be the row's own — not the session's. + if persistedName != "OpenAI" { + t.Fatalf("persisted row = %q, want the row's spelling OpenAI", persistedName) + } cfg := readTUIConfigFixture(t, configPath) if cfg.Providers[0].Model != "gpt-5.5" { t.Fatalf("model = %q, want gpt-5.5 written to the OpenAI row", cfg.Providers[0].Model) @@ -75,6 +80,74 @@ func TestModelPersistenceUsesResolvedPersistedSpelling(t *testing.T) { }) } +// The provider manager's rows and the picker's model sections are built from +// savedProviders, not from the live profile: a switch that updates the client +// and config.json without mirroring the list leaves those surfaces showing the +// previous model until the TUI restarts and re-resolves providers from config. +func TestModelSwitchSyncsSavedProviders(t *testing.T) { + newSession := func(t *testing.T) model { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"activeProvider":"OpenAI","providers":[{"name":"OpenAI","catalogID":"openai","model":"gpt-5.1"},{"name":"ollama","catalogID":"ollama","provider_kind":"openai-compatible","baseURL":"http://localhost:11434/v1","model":"m1"}]}`), 0o600); err != nil { + t.Fatal(err) + } + saved := []config.ProviderProfile{ + {Name: "OpenAI", CatalogID: "openai", Model: "gpt-5.1", APIKey: "sk-test"}, + {Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "m1"}, + } + return newModel(context.Background(), Options{ + UserConfigPath: path, + ProviderName: "ollama", + ModelName: "m1", + Provider: &fakeProvider{}, + ProviderProfile: saved[1], + SavedProviders: saved, + NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { + return &fakeProvider{}, nil + }, + }) + } + + t.Run("switchProviderModel", func(t *testing.T) { + m := newSession(t) + // "openai" is the picker row's owner spelling, not the persisted one: + // the mirror must land on the row SetProviderModel actually wrote. + next, status, ok, _ := m.switchProviderModel("openai", "gpt-5.5") + if !ok { + t.Fatalf("switch failed: %s", status) + } + if next.savedProviders[0].Model != "gpt-5.5" { + t.Fatalf("savedProviders model = %q, want the switched gpt-5.5 without a restart", next.savedProviders[0].Model) + } + if next.savedProviders[1].Model != "m1" { + t.Fatalf("switch touched an unrelated row: %+v", next.savedProviders[1]) + } + // The manager renders each row's model straight off this list. + if meta := providerManagerRowMeta(next.savedProviders[0]); !strings.Contains(meta, "gpt-5.5") { + t.Fatalf("manager row meta = %q, want the switched model", meta) + } + }) + + t.Run("persistSelectedModel mirror", func(t *testing.T) { + m := newSession(t) + profile := config.ProviderProfile{Name: "openai", Model: "gpt-5.5"} + persisted, persistedName, err := m.persistSelectedModel(profile) + if err != nil { + t.Fatal(err) + } + if !persisted { + t.Fatal("expected the persisted row to be written") + } + saved := syncSavedProviderModel(m.savedProviders, persistedName, profile.Model) + if saved[0].Model != "gpt-5.5" { + t.Fatalf("savedProviders model = %q, want gpt-5.5", saved[0].Model) + } + if saved[1].Model != "m1" { + t.Fatalf("mirror touched an unrelated row: %+v", saved[1]) + } + }) +} + // The switch deliberately continues in-session when config.json cannot be // updated, so the note is the only thing telling the user the two now disagree. // Silence here would read as a saved switch that was never persisted. diff --git a/internal/tui/provider_manager.go b/internal/tui/provider_manager.go index 80e3b3ff6..3f1b11c4a 100644 --- a/internal/tui/provider_manager.go +++ b/internal/tui/provider_manager.go @@ -106,8 +106,10 @@ func (m model) reloadProviderManagerRows() (model, tea.Cmd) { m.providerWizard.manageRows = rows m.providerWizard.manageCursor = clampInt(m.providerWizard.manageCursor, 0, maxInt(0, len(rows)-1)) // The session's live provider is the truth the user cares about (config's - // activeProvider follows it on every switch). - m.providerWizard.manageActiveName = m.providerName + // activeProvider follows it on every switch). Resolve it to the row it + // refers to once, here, so the render's exact comparison and the sync paths + // below share one value instead of each re-deciding what "active" means. + m.providerWizard.manageActiveName = sessionRowName(m.providerName, m.savedProviders) m.providerWizard.manageCredGen++ return m, providerManagerCredsCmd(m.providerWizard.manageCredGen, rows, m.userConfigPath) } @@ -390,11 +392,16 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { } } + // Decide whether the deleted row is the one this session runs on BEFORE the + // list shrinks: sessionRowName counts identity-carrying rows, and removing + // one of them changes that count. + deletedLiveRow := sessionRefersToPersistedRow(m.providerName, name, m.savedProviders) + // Surgical removal — see saveManagerEdit for why the raw cfg.Providers list // must not replace the resolved/filtered savedProviders wholesale. m.savedProviders = removeSavedProvider(m.savedProviders, name) - if samePersistedProviderName(m.providerName, name) { + if deletedLiveRow { notes = append(notes, "This session keeps running on it until you switch.") } else if activeAfter != "" && !samePersistedProviderName(activeAfter, name) { notes = append(notes, "Active provider: "+activeAfter+".") @@ -446,6 +453,54 @@ func samePersistedProviderName(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) } +// sessionRowName resolves the LIVE session's provider spelling to the persisted +// row it actually refers to. This answers a third question, distinct from the +// two identity rules config defines: not "which stored secret is this?" +// (config.SameProviderIdentity) and not "which row does this mutator target?" +// (exact trimmed equality), but "is this the provider I am running on?". +// +// An exact spelling always wins, so sibling rows that differ only by case +// ("work" and "WORK") stay distinct — a session on "work" must never follow an +// edit or delete aimed at "WORK", and "s"/"ſ" must not re-merge. Only when the +// credential identity is carried by exactly ONE row is the session's spelling +// resolved to that row's own, which is what lines a session launched with +// ZERO_PROVIDER=openai (or resumed session metadata, or a `zero providers use +// openai` run in another terminal) up with the sole saved "OpenAI" row. +// +// When nothing resolves — env-derived providers, ambiguous duplicate identities +// — the live spelling comes back unchanged, so every comparison built on this +// degrades to exact equality rather than guessing. +func sessionRowName(live string, providers []config.ProviderProfile) string { + live = strings.TrimSpace(live) + if live == "" { + return "" + } + match := "" + matches := 0 + for _, provider := range providers { + name := strings.TrimSpace(provider.Name) + if name == live { + return name + } + if config.SameProviderIdentity(name, live) { + match = name + matches++ + } + } + if matches == 1 { + return match + } + return live +} + +// sessionRefersToPersistedRow reports whether the live session runs on row. +// See sessionRowName for why this is neither blind SameProviderIdentity nor +// plain exact equality. +func sessionRefersToPersistedRow(live string, row string, providers []config.ProviderProfile) bool { + resolved := sessionRowName(live, providers) + return resolved != "" && resolved == strings.TrimSpace(row) +} + // providerManagerCleanupMsg reports the off-thread half of a delete: the // stored-key removal outcome and the OAuth-login hint. type providerManagerCleanupMsg struct { @@ -669,7 +724,9 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { captured := config.SecureProviderProfile(config.ProviderProfile{Name: exactName, APIKey: key}, m.userConfigPath) // On a store failure SecureProviderProfile keeps the inline key, which // EditProvider then persists (the startup migration re-captures later) — - // the same fail-soft posture as every other capture path. + // the same fail-soft posture as every other capture path. A failed + // EditProvider below does not roll the capture back either; atomic + // capture+publish for this path is #894, not this PR. edit.APIKey = captured.APIKey edit.APIKeyStored = captured.APIKeyStored } @@ -677,6 +734,11 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { wizard.err = err.Error() return m, nil } + // Decide whether the edited row is the live one BEFORE the list is rewritten: + // a rename changes which rows carry the session's credential identity, and + // sessionRowName's sole-row resolution depends on that count. + editedLiveRow := sessionRefersToPersistedRow(m.providerName, oldName, m.savedProviders) + // Mirror the edit into the in-memory list surgically. savedProviders was // seeded from the RESOLVED (project-config layered) and usability-FILTERED // provider set — substituting the raw user-file list here would drop @@ -685,7 +747,7 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { // Keep the live session's identity in sync with a rename of the provider it // is running on: the exported ZERO_PROVIDER must resolve for spawned children. - if samePersistedProviderName(m.providerName, oldName) { + if editedLiveRow { m.providerName = newName m.providerProfile.Name = newName config.SetActiveProviderEnv(newName) @@ -693,7 +755,7 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { wizard.step = providerWizardStepManage next, cmd := m.reloadProviderManagerRows() - next.providerWizard.manageStatus = "Updated " + newName + "." + providerEditRestartNote(next.providerName, newName) + next.providerWizard.manageStatus = "Updated " + newName + "." + providerEditRestartNote(next.providerName, newName, next.savedProviders) return next, cmd } @@ -701,14 +763,38 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { // this session is running on — endpoint/model/key changes only apply to the // built client after a switch (Enter on the row re-activates and rebuilds). // liveName is the session's provider AFTER any rename sync, so a single -// comparison against the edited profile's final name suffices. -func providerEditRestartNote(liveName string, editedName string) string { - if samePersistedProviderName(liveName, editedName) { +// comparison against the edited profile's final name suffices — routed through +// sessionRefersToPersistedRow so a sole row the session spells differently +// (live "openai", row "OpenAI") still gets the note, while case-variant +// siblings do not. +func providerEditRestartNote(liveName string, editedName string, providers []config.ProviderProfile) string { + if sessionRefersToPersistedRow(liveName, editedName, providers) { return " Press Enter on it to apply the changes to this session." } return "" } +// syncSavedProviderModel mirrors a model that was just written to config.json +// into the in-memory saved list — the single reconciliation point every path +// that persists a model must call. +// +// The provider manager builds its rows from savedProviders (see +// reloadProviderManagerRows) and renders each row's model from that list, as do +// the picker's saved-provider model sections. A switch that updates the live +// client and config.json but not this list leaves those surfaces showing the +// previous model until the TUI restarts and re-resolves providers from config +// — the same "disk says X, session says Y" drift the wizard's key removal +// fixed with applyProviderKeyRemovalToSession. +// +// exactName must be the PERSISTED row's spelling — the one SetProviderModel was +// handed, not the session's — because savedProviders carries row spellings. +func syncSavedProviderModel(saved []config.ProviderProfile, exactName string, model string) []config.ProviderProfile { + if strings.TrimSpace(exactName) == "" || strings.TrimSpace(model) == "" { + return saved + } + return applySavedProviderEdit(saved, exactName, config.ProviderEdit{Name: exactName, Model: model}) +} + // applySavedProviderEdit mirrors a persisted config.EditProvider into the // in-memory saved list without wholesale replacement (see saveManagerEdit). func applySavedProviderEdit(saved []config.ProviderProfile, oldName string, edit config.ProviderEdit) []config.ProviderProfile { diff --git a/internal/tui/provider_manager_test.go b/internal/tui/provider_manager_test.go index 342fc8858..fa4d38941 100644 --- a/internal/tui/provider_manager_test.go +++ b/internal/tui/provider_manager_test.go @@ -749,7 +749,13 @@ func TestProviderManagerKeepsDistinctUnicodeLiveProviderOnOtherRowMutation(t *te }) } -func TestProviderManagerCaseVariantEditDoesNotChangeLiveSibling(t *testing.T) { +// A session can spell its provider differently from the row it runs on — +// ZERO_PROVIDER=work against a saved "WORK", resumed session metadata, or a +// `zero providers use work` from another terminal. When that row is the SOLE +// carrier of the credential identity there is no other row the session could +// mean, so the manager must mark it active and carry a rename onto the live +// session; otherwise ZERO_PROVIDER keeps exporting a name no row answers to. +func TestProviderManagerSoleRowCaseVariantTracksLiveSession(t *testing.T) { t.Setenv(config.ActiveProviderEnv, "work") profile := config.ProviderProfile{ Name: "WORK", @@ -772,18 +778,25 @@ func TestProviderManagerCaseVariantEditDoesNotChangeLiveSibling(t *testing.T) { UserConfigPath: path, }) m, _ = m.openProviderManager() + + // The row the session actually runs on must render as active even though + // the session spells it differently. + if got := m.providerWizard.manageActiveName; got != "WORK" { + t.Fatalf("manageActiveName = %q, want the sole row's spelling WORK", got) + } + m.providerWizard.beginProviderEdit(profile) m.providerWizard.editDraft.Name = "OFFICE" next, _ := m.saveManagerEdit() - if next.providerName != "work" || next.providerProfile.Name != "work" { - t.Fatalf("editing WORK rewrote live work identity: name=%q profile=%q", next.providerName, next.providerProfile.Name) + if next.providerWizard == nil || next.providerWizard.err != "" { + t.Fatalf("sole-row case-variant edit failed: %+v", next.providerWizard) } - if got := os.Getenv(config.ActiveProviderEnv); got != "work" { - t.Fatalf("%s = %q, want live work unchanged", config.ActiveProviderEnv, got) + if next.providerName != "OFFICE" || next.providerProfile.Name != "OFFICE" { + t.Fatalf("rename did not follow the live session: name=%q profile=%q", next.providerName, next.providerProfile.Name) } - if next.providerWizard == nil || next.providerWizard.err != "" { - t.Fatalf("case-variant sibling edit failed: %+v", next.providerWizard) + if got := os.Getenv(config.ActiveProviderEnv); got != "OFFICE" { + t.Fatalf("%s = %q, want the renamed row so spawned children resolve it", config.ActiveProviderEnv, got) } if len(next.savedProviders) != 1 || next.savedProviders[0].Name != "OFFICE" { t.Fatalf("wrong in-memory edit target: %+v", next.savedProviders) @@ -794,6 +807,50 @@ func TestProviderManagerCaseVariantEditDoesNotChangeLiveSibling(t *testing.T) { } } +// The sole-row resolution above must NOT reach case-variant siblings: with both +// "work" and "WORK" persisted, a session on "work" is one specific row, and a +// delete aimed at the other must leave it alone. (Edit cannot be exercised here +// — EditProvider validates the duplicate-identity config before mutating.) +func TestProviderManagerCaseVariantDeleteDoesNotChangeLiveSibling(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + t.Setenv(config.ActiveProviderEnv, "work") + profiles := []config.ProviderProfile{ + {Name: "work", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://work.example/v1", Model: "work-model"}, + {Name: "WORK", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://other.example/v1", Model: "other-model"}, + } + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"activeProvider":"work","providers":[{"name":"work"},{"name":"WORK"}]}`), 0o600); err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + ProviderName: "work", + ProviderProfile: profiles[0], + SavedProviders: profiles, + UserConfigPath: path, + }) + m, _ = m.openProviderManager() + // Exact spelling wins, so the live row is "work" and not its sibling. + if got := m.providerWizard.manageActiveName; got != "work" { + t.Fatalf("manageActiveName = %q, want the exact live row work", got) + } + + m.providerWizard.manageCursor = 1 + next, _ := m.deleteManagerSelection() + + if next.providerName != "work" || next.providerProfile.Name != "work" { + t.Fatalf("deleting WORK rewrote live work identity: name=%q profile=%q", next.providerName, next.providerProfile.Name) + } + if got := os.Getenv(config.ActiveProviderEnv); got != "work" { + t.Fatalf("%s = %q, want live work unchanged", config.ActiveProviderEnv, got) + } + if status := next.providerWizard.manageStatus; strings.Contains(status, "keeps running on it until you switch") { + t.Fatalf("delete of the sibling row claimed the live session runs on it: %q", status) + } + if len(next.savedProviders) != 1 || next.savedProviders[0].Name != "work" { + t.Fatalf("wrong in-memory removal target: %+v", next.savedProviders) + } +} + // The confirmation prompt must promise what the delete actually does: with a // case variant that still claims the shared credential, the key is kept, so // the prompt must not say it is about to be removed. diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index 262e1723b..cc5a2e9e6 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -1274,6 +1274,8 @@ func (m model) applyProviderWizard() (model, tea.Cmd) { // Capture flip: move the freshly entered key into the encrypted credential // store before persisting, so config.json never holds the cleartext. The // provider was already built above from runtimeProfile, which has the key. + // Fail-soft capture with no rollback if the config write below fails — + // see the note in cli/provider_setup.go; atomicity is #894. secret := profile.APIKey if !preserveExistingCredentialReference { profile = config.SecureProviderProfile(profile, m.userConfigPath) From c3f1f6ddcb7cc95802919a0d09248a8a2effbc32 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 18 Aug 2026 20:46:09 +0000 Subject: [PATCH 10/17] fix(provider): close remaining identity review findings Amp-Thread-ID: https://ampcode.com/threads/T-01a01695-5a8b-753c-bbe3-4a14ac881d7e Co-authored-by: Pierre Bruno --- internal/cli/auth.go | 25 ++++----- internal/cli/auth_test.go | 47 +++++++++++++++++ internal/cli/provider_onboarding.go | 9 +++- internal/cli/provider_onboarding_test.go | 65 ++++++++++++++++++++++++ internal/tui/command_center_test.go | 26 +++++----- internal/tui/provider_manager.go | 3 +- internal/tui/provider_manager_test.go | 32 ++++++++++++ 7 files changed, 174 insertions(+), 33 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index f6dbfc1b1..8a29c9121 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -442,12 +442,12 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe return writeExecUsageError(stderr, "usage: zero auth logout ") } provider := parsed.positional[0] - configPath := "" - if path, pathErr := deps.userConfigPath(); pathErr == nil { - configPath = path - if err := config.PreflightUserConfig(configPath); err != nil { - return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) - } + configPath, err := deps.userConfigPath() + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if err := config.PreflightUserConfig(configPath); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) } manager, err := newAuthManager(deps, stdout) if err != nil { @@ -465,17 +465,10 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe // store BESIDE the config being edited (where setup/rename captured the key), // so a non-default config path cannot clear a marker here while the secret // stays in the default-path store. - if configPath != "" { - if _, clearErr := config.ClearProviderKeyStoredCaseVariants(configPath, provider); clearErr != nil { - return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash) - } - } - keyRemoved, keyErr := false, error(nil) - if configPath != "" { - keyRemoved, keyErr = removeStoredProviderKeyAt(configPath, provider) - } else { - keyRemoved, keyErr = config.ForgetProviderKey(provider) + if _, clearErr := config.ClearProviderKeyStoredCaseVariants(configPath, provider); clearErr != nil { + return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash) } + keyRemoved, keyErr := removeStoredProviderKeyAt(configPath, provider) if keyErr != nil { return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash) } diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 7a312bba1..25060dc5e 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -415,6 +416,49 @@ func TestRunAuthLogoutRejectsAmbiguousConfigBeforeCredentialDeletion(t *testing. } } +func TestRunAuthLogoutRejectsConfigPathFailureBeforeCredentialDeletion(t *testing.T) { + withAuthStore(t) + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCLIUserConfigRoot(t) + store, err := config.ProviderKeyStore() + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "sk-shared"); err != nil { + t.Fatal(err) + } + oauthStore, err := oauth.NewStore(oauth.StoreOptions{}) + if err != nil { + t.Fatal(err) + } + oauthToken := oauth.Token{AccessToken: "oauth-access", RefreshToken: "oauth-refresh", Account: "work@example.com"} + if err := oauthStore.Save(oauth.ProviderKey("work"), oauthToken); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + pathErr := errors.New("config path unavailable") + code := runWithDeps([]string{"auth", "logout", "work"}, &stdout, &stderr, appDeps{ + userConfigPath: func() (string, error) { return "", pathErr }, + }) + if code != exitCrash { + t.Fatalf("logout exit = %d, want path failure", code) + } + if !strings.Contains(stderr.String(), pathErr.Error()) { + t.Fatalf("stderr = %q, want config path failure", stderr.String()) + } + if key, ok, getErr := store.Get("work"); getErr != nil || !ok || key != "sk-shared" { + t.Fatalf("API credential changed before path rejection: present=%v len=%d err=%v", ok, len(key), getErr) + } + storedOAuth, ok, loadErr := oauthStore.Load(oauth.ProviderKey("work")) + if loadErr != nil || !ok { + t.Fatalf("OAuth credential missing after path rejection: ok=%v err=%v", ok, loadErr) + } + if storedOAuth.AccessToken != oauthToken.AccessToken || storedOAuth.RefreshToken != oauthToken.RefreshToken || storedOAuth.Account != oauthToken.Account { + t.Fatal("OAuth credential changed before config path rejection") + } +} + // A legacy duplicate-row config cannot be published, and the rejection must not // cost the user the OpenRouter key they were already working with: the capture // is validated first, and a rejected publication restores the previous secret @@ -447,6 +491,9 @@ func TestRunAuthOpenRouterPreservesExistingKeyWhenConfigRejected(t *testing.T) { if code == exitSuccess { t.Fatalf("exit = %d, want non-zero for an unsaved login: %s", code, stdout.String()) } + if !strings.Contains(stdout.String(), "ambiguous persisted provider names") { + t.Fatalf("stdout = %q, want ambiguous persisted-name rejection", stdout.String()) + } after, err := os.ReadFile(configPath) if err != nil { t.Fatal(err) diff --git a/internal/cli/provider_onboarding.go b/internal/cli/provider_onboarding.go index 677b4964a..dce7bab09 100644 --- a/internal/cli/provider_onboarding.go +++ b/internal/cli/provider_onboarding.go @@ -11,6 +11,7 @@ import ( "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/providercatalog" "github.com/Gitlawb/zero/internal/provideronboarding" + "github.com/Gitlawb/zero/internal/redaction" ) type providerUseOptions struct { @@ -444,20 +445,24 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps } if keyErr != nil { // A lingering secret must not read as a clean removal. - payload["keyError"] = keyErr.Error() + payload["keyError"] = redaction.ErrorMessage(keyErr, redaction.Options{}) } if err := writePrettyJSON(stdout, payload); err != nil { return exitCrash } + if keyErr != nil { + return exitCrash + } return exitSuccess } if _, err := fmt.Fprintf(stdout, "Removed provider %s\n", name); err != nil { return exitCrash } if keyErr != nil { - 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 { + if _, err := fmt.Fprintf(stderr, "warning: its stored API key could not be deleted and remains in the credential store: %s\n", redaction.ErrorMessage(keyErr, redaction.Options{})); err != nil { return exitCrash } + return exitCrash } else if keyRemoved { if _, err := fmt.Fprintln(stdout, "Deleted its stored API key."); err != nil { return exitCrash diff --git a/internal/cli/provider_onboarding_test.go b/internal/cli/provider_onboarding_test.go index bde220141..42f4e53bf 100644 --- a/internal/cli/provider_onboarding_test.go +++ b/internal/cli/provider_onboarding_test.go @@ -481,6 +481,71 @@ func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) { } } +func TestRunProvidersRemoveFailsWhenStoredKeyCleanupFails(t *testing.T) { + for _, jsonOutput := range []bool{false, true} { + name := "text" + if jsonOutput { + name = "json" + } + t.Run(name, func(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "file") + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":[{"name":"gw","apiKeyStored":true}]}`), 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStoreAt(dir) + if err != nil { + t.Fatal(err) + } + if err := store.Set("gw", "sk-secret"); err != nil { + t.Fatal(err) + } + // A directory at the lock-file path is a hermetic, cross-platform + // failure: Delete cannot acquire its write lock. + lockPath := filepath.Join(dir, "credentials.json.lock") + if err := os.Remove(lockPath); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(lockPath, 0o700); err != nil { + t.Fatal(err) + } + + args := []string{"providers", "remove", "gw"} + if jsonOutput { + args = append(args, "--json") + } + var stdout, stderr bytes.Buffer + code := runWithDeps(args, &stdout, &stderr, appDeps{ + userConfigPath: func() (string, error) { return configPath, nil }, + }) + if code != exitCrash { + t.Fatalf("exit = %d, want cleanup failure; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if jsonOutput { + var payload struct { + KeyError string `json:"keyError"` + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if payload.KeyError == "" { + t.Fatal("JSON cleanup failure omitted keyError") + } + } else if !strings.Contains(stderr.String(), "could not be deleted") { + t.Fatalf("stderr = %q, want cleanup warning", stderr.String()) + } + + if err := os.Remove(lockPath); err != nil { + t.Fatal(err) + } + if key, ok, getErr := store.Get("gw"); getErr != nil || !ok || key != "sk-secret" { + t.Fatalf("failed cleanup changed key: present=%v len=%d err=%v", ok, len(key), getErr) + } + }) + } +} + func TestRunProvidersRemoveKeepsSharedCredentialForCaseVariantSurvivor(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") dir := t.TempDir() diff --git a/internal/tui/command_center_test.go b/internal/tui/command_center_test.go index 6839010a6..cf322041f 100644 --- a/internal/tui/command_center_test.go +++ b/internal/tui/command_center_test.go @@ -128,22 +128,20 @@ func TestModelSwitchSyncsSavedProviders(t *testing.T) { } }) - t.Run("persistSelectedModel mirror", func(t *testing.T) { + t.Run("handleModelCommand", func(t *testing.T) { m := newSession(t) - profile := config.ProviderProfile{Name: "openai", Model: "gpt-5.5"} - persisted, persistedName, err := m.persistSelectedModel(profile) - if err != nil { - t.Fatal(err) - } - if !persisted { - t.Fatal("expected the persisted row to be written") - } - saved := syncSavedProviderModel(m.savedProviders, persistedName, profile.Model) - if saved[0].Model != "gpt-5.5" { - t.Fatalf("savedProviders model = %q, want gpt-5.5", saved[0].Model) + // Exercise the production caller that owns both persistence and the + // savedProviders mirror; calling the two helpers separately would stay + // green if their production pairing were removed. + m.providerName = "openai" + m.providerProfile = m.savedProviders[0] + m.modelName = m.providerProfile.Model + next, status := m.handleModelCommand("gpt-4.1-mini") + if next.savedProviders[0].Model != "gpt-4.1-mini" { + t.Fatalf("savedProviders model = %q, want gpt-4.1-mini; status=%q", next.savedProviders[0].Model, status) } - if saved[1].Model != "m1" { - t.Fatalf("mirror touched an unrelated row: %+v", saved[1]) + if next.savedProviders[1].Model != "m1" { + t.Fatalf("mirror touched an unrelated row: %+v", next.savedProviders[1]) } }) } diff --git a/internal/tui/provider_manager.go b/internal/tui/provider_manager.go index 3f1b11c4a..3e5124b5d 100644 --- a/internal/tui/provider_manager.go +++ b/internal/tui/provider_manager.go @@ -17,6 +17,7 @@ import ( "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/oauth" + "github.com/Gitlawb/zero/internal/redaction" ) const providerManagerMaxVisible = 10 @@ -523,7 +524,7 @@ func providerManagerCleanupCmd(configPath string, profile config.ProviderProfile _, storeErr = keyStore.Delete(name) } if storeErr != nil { - notes = append(notes, "Warning: its stored API key could not be deleted ("+storeErr.Error()+").") + notes = append(notes, "Warning: its stored API key could not be deleted ("+redaction.ErrorMessage(storeErr, redaction.Options{})+").") } } if login, ok := oauthLoginName(config.ProviderProfile{Name: name, CatalogID: catalogID}); ok { diff --git a/internal/tui/provider_manager_test.go b/internal/tui/provider_manager_test.go index fa4d38941..6f87f7877 100644 --- a/internal/tui/provider_manager_test.go +++ b/internal/tui/provider_manager_test.go @@ -851,6 +851,38 @@ func TestProviderManagerCaseVariantDeleteDoesNotChangeLiveSibling(t *testing.T) } } +func TestProviderManagerAmbiguousCaseVariantSessionDoesNotGuessLiveRow(t *testing.T) { + providers := []config.ProviderProfile{{Name: "work"}, {Name: "WORK"}} + if got := sessionRowName("Work", providers); got != "Work" { + t.Fatalf("sessionRowName = %q, want unresolved live spelling Work", got) + } + for _, row := range providers { + if sessionRefersToPersistedRow("Work", row.Name, providers) { + t.Fatalf("ambiguous live spelling must not select row %q", row.Name) + } + } +} + +func TestProviderManagerCleanupRedactsCredentialStoreError(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "file") + secret := "sk-proj-12345678901234567890" + dir := filepath.Join(t.TempDir(), secret) + if err := os.MkdirAll(filepath.Join(dir, "credentials.json.lock"), 0o700); err != nil { + t.Fatal(err) + } + msg, ok := providerManagerCleanupCmd(filepath.Join(dir, "config.json"), config.ProviderProfile{Name: "work"}, true)().(providerManagerCleanupMsg) + if !ok { + t.Fatal("cleanup command returned the wrong message type") + } + text := strings.Join(msg.notes, " ") + if strings.Contains(text, secret) { + t.Fatalf("cleanup warning leaked credential-like text: %q", text) + } + if !strings.Contains(text, "could not be deleted") { + t.Fatalf("cleanup warning missing failure context: %q", text) + } +} + // The confirmation prompt must promise what the delete actually does: with a // case variant that still claims the shared credential, the key is kept, so // the prompt must not say it is about to be removed. From 35fe02a69bcf0729ad6ae60efeff7c2eab43b0df Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Thu, 20 Aug 2026 22:32:18 +0200 Subject: [PATCH 11/17] fix(provider): preserve legacy config and OAuth state Amp-Thread-ID: https://ampcode.com/threads/T-01a020b3-4e7a-732b-aef1-b6fafd87b569 Co-authored-by: Amp --- internal/cli/auth.go | 18 +++++ internal/cli/auth_test.go | 41 +++++++++++ internal/cli/command_center.go | 7 ++ internal/cli/provider_onboarding.go | 83 ++++++++++++++++++++++ internal/cli/provider_onboarding_test.go | 46 ++++++++++++ internal/config/writer.go | 53 +++++++++++++- internal/config/writer_test.go | 79 ++++++++++++++++++++ internal/oauth/manager.go | 20 +++++- internal/oauth/manager_test.go | 31 ++++++++ internal/tui/oauth_device.go | 14 +++- internal/tui/onboarding.go | 24 ++++--- internal/tui/provider_wizard.go | 38 +++++++--- internal/tui/provider_wizard_discovery.go | 2 +- internal/tui/provider_wizard_oauth_test.go | 46 ++++++++++++ 14 files changed, 478 insertions(+), 24 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 8a29c9121..a93894519 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -53,6 +53,14 @@ func ensureLoginProviderProfile(deps appDeps, provider string) string { } } +func preflightAuthLogin(deps appDeps) error { + configPath, err := deps.userConfigPath() + if err != nil { + return err + } + return config.PreflightUserConfig(configPath) +} + // runAuth dispatches `zero auth ` for provider OAuth login. It is // additive and independent of `zero mcp oauth` (MCP server auth), which is // unchanged. @@ -180,6 +188,9 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD if len(args) > 0 { return writeExecUsageError(stderr, fmt.Sprintf("zero auth chatgpt takes no arguments (got %q)", args[0])) } + if err := preflightAuthLogin(deps); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } // Build the same env map the oauth engine reads so the chatgpt preset is // opted into (the preset is off by default to keep third-party OAuth @@ -216,6 +227,9 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) } + if err := preflightAuthLogin(deps); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } if err := store.Save(oauth.ProviderKey("chatgpt"), token); err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) } @@ -376,6 +390,7 @@ func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) { // `zero auth login ` (e.g. xai) should resolve the baked-in preset // without the operator exporting ZERO_OAUTH_ALLOW_PRESETS first. AllowPresets: true, + BeforeSave: func() error { return preflightAuthLogin(deps) }, }) } @@ -406,6 +421,9 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep } return runAuthChatGPT(nil, stdout, stderr, deps) } + if err := preflightAuthLogin(deps); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } manager, err := newAuthManager(deps, stdout) if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 25060dc5e..dc30f5d38 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -416,6 +416,47 @@ func TestRunAuthLogoutRejectsAmbiguousConfigBeforeCredentialDeletion(t *testing. } } +func TestRunAuthLoginRejectsAmbiguousConfigBeforeTokenReplacement(t *testing.T) { + for _, test := range []struct { + name string + provider string + args []string + }{ + {name: "generic", provider: "xai", args: []string{"auth", "login", "xai"}}, + {name: "chatgpt", provider: "chatgpt", args: []string{"auth", "chatgpt"}}, + } { + t.Run(test.name, func(t *testing.T) { + withAuthStore(t) + configPath := filepath.Join(t.TempDir(), "config.json") + seed := []byte(`{"providers":[{"name":"xai"},{"name":"XAI"}]}`) + if err := os.WriteFile(configPath, seed, 0o600); err != nil { + t.Fatal(err) + } + store, err := oauth.NewStore(oauth.StoreOptions{}) + if err != nil { + t.Fatal(err) + } + previous := oauth.Token{AccessToken: "previous-access", RefreshToken: "previous-refresh", Account: "previous-account"} + if err := store.Save(oauth.ProviderKey(test.provider), previous); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := runWithDeps(test.args, &stdout, &stderr, appDeps{userConfigPath: func() (string, error) { return configPath, nil }}) + if code != exitCrash || !strings.Contains(stderr.String(), "ambiguous persisted provider names") { + t.Fatalf("login exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + stored, ok, err := store.Load(oauth.ProviderKey(test.provider)) + if err != nil || !ok || stored.AccessToken != previous.AccessToken || stored.RefreshToken != previous.RefreshToken || stored.Account != previous.Account { + t.Fatalf("rejected login changed previous token: ok=%v err=%v", ok, err) + } + after, err := os.ReadFile(configPath) + if err != nil || !bytes.Equal(after, seed) { + t.Fatalf("rejected login changed config: readErr=%v", err) + } + }) + } +} + func TestRunAuthLogoutRejectsConfigPathFailureBeforeCredentialDeletion(t *testing.T) { withAuthStore(t) t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") diff --git a/internal/cli/command_center.go b/internal/cli/command_center.go index a6fab33ec..8ebab9f98 100644 --- a/internal/cli/command_center.go +++ b/internal/cli/command_center.go @@ -81,6 +81,9 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep if command == "rename" { return runProvidersRename(args, stdout, stderr, deps) } + if command == "repair-config" { + return runProvidersRepairConfig(args, stdout, stderr, deps) + } if command == "setup" { return runProvidersSetup(args, stdout, stderr, deps) } @@ -497,6 +500,7 @@ func writeProvidersHelp(w io.Writer) error { zero providers use [flags] zero providers remove [flags] zero providers rename [flags] + zero providers repair-config [flags] zero providers setup [flags] zero providers detect [flags] zero providers models [name] [flags] @@ -527,6 +531,9 @@ Setup flags: --base-url Planned base URL override --api-key-env Planned API key environment variable --set-active Include --set-active in the add command + +Repair-config flags: + --name Explicit name for the legacy unnamed provider -h, --help Show this help `) return err diff --git a/internal/cli/provider_onboarding.go b/internal/cli/provider_onboarding.go index dce7bab09..59e0b8e4e 100644 --- a/internal/cli/provider_onboarding.go +++ b/internal/cli/provider_onboarding.go @@ -38,6 +38,11 @@ type providerSetupPlan struct { EnvVar string `json:"envVar"` } +type providerRepairOptions struct { + name string + json bool +} + func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { options, help, err := parseProviderUseArgs(args) if err != nil { @@ -380,6 +385,84 @@ func parseProviderNamesArgs(args []string, want int, usage string) (providerName return options, false, nil } +func runProvidersRepairConfig(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + options, help, err := parseProviderRepairArgs(args) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writeProvidersHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + configPath, err := deps.userConfigPath() + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + cfg, err := config.RepairUnnamedProvider(configPath, options.name) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + repaired := "" + for _, provider := range cfg.Providers { + if options.name != "" && provider.Name == strings.TrimSpace(options.name) { + repaired = provider.Name + break + } + } + if repaired == "" { + repaired = strings.TrimSpace(options.name) + if repaired == "" { + repaired = strings.TrimSpace(cfg.ActiveProvider) + } + if repaired == "" { + repaired = "openai" + } + } + if options.json { + if err := writePrettyJSON(stdout, map[string]any{"repairedProvider": repaired, "configPath": configPath}); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "Named legacy provider %s in %s\n", repaired, configPath); err != nil { + return exitCrash + } + return exitSuccess +} + +func parseProviderRepairArgs(args []string) (providerRepairOptions, bool, error) { + options := providerRepairOptions{} + for index := 0; index < len(args); index++ { + arg := args[index] + switch { + case arg == "-h" || arg == "--help" || arg == "help": + return options, true, nil + case arg == "--json": + options.json = true + case arg == "--name": + value, next, err := nextFlagValue(args, index, arg) + if err != nil { + return options, false, err + } + options.name = value + index = next + case strings.HasPrefix(arg, "--name="): + value, err := requiredInlineFlagValue(arg, "--name") + if err != nil { + return options, false, err + } + options.name = value + case strings.HasPrefix(arg, "-"): + return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)} + default: + return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)} + } + } + return options, false, nil +} + // runProvidersRemove deletes a saved provider profile and its stored API key. // The OAuth token (if any) is kept — logins outlive profiles so re-adding the // provider needs no new browser round-trip; `zero auth logout ` removes it. diff --git a/internal/cli/provider_onboarding_test.go b/internal/cli/provider_onboarding_test.go index 42f4e53bf..72737160d 100644 --- a/internal/cli/provider_onboarding_test.go +++ b/internal/cli/provider_onboarding_test.go @@ -43,6 +43,52 @@ func TestRunProvidersUseSetsActiveProvider(t *testing.T) { } } +func TestRunProvidersRepairConfigRecoversLegacyUnnamedProvider(t *testing.T) { + for _, jsonOutput := range []bool{false, true} { + name := "text" + if jsonOutput { + name = "json" + } + t.Run(name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + configPath := filepath.Join(t.TempDir(), "zero", "config.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatal(err) + } + seed := []byte(`{"activeProvider":"legacy","providers":[{"name":"","provider_kind":"openai","model":"gpt-4o"}],"maxTurns":17}`) + if err := os.WriteFile(configPath, seed, 0o600); err != nil { + t.Fatal(err) + } + if _, err := config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}}); err == nil { + t.Fatal("legacy unnamed config unexpectedly resolved before repair") + } + args := []string{"providers", "repair-config"} + if jsonOutput { + args = append(args, "--json") + } + code := runWithDeps(args, &stdout, &stderr, providerSetupDeps(configPath)) + if code != exitSuccess { + t.Fatalf("repair exit = %d, stderr=%q", code, stderr.String()) + } + resolved, err := config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}}) + if err != nil { + t.Fatalf("repaired config does not resolve: %v", err) + } + if resolved.ActiveProvider != "legacy" || resolved.Provider.Name != "legacy" || resolved.Provider.Model != "gpt-4o" || resolved.MaxTurns != 17 { + t.Fatalf("resolved repaired config = %+v", resolved) + } + if jsonOutput { + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil || payload["repairedProvider"] != "legacy" { + t.Fatalf("repair JSON = %q, err=%v", stdout.String(), err) + } + } else if !strings.Contains(stdout.String(), "Named legacy provider legacy") { + t.Fatalf("repair output = %q", stdout.String()) + } + }) + } +} + func TestRunProvidersUseJSONIncludesActiveProviderAndConfigPath(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/config/writer.go b/internal/config/writer.go index 49977290e..2316cbf69 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -28,7 +28,7 @@ func ValidatePersistedProviderNames(cfg FileConfig) error { for _, provider := range cfg.Providers { name := strings.TrimSpace(provider.Name) if name == "" { - return fmt.Errorf("persisted provider name cannot be empty; name the provider explicitly in config.json") + return fmt.Errorf("persisted provider name cannot be empty; run `zero providers repair-config` to name the legacy provider") } folded := credstore.NormalizeProvider(name) previous, ok := seen[folded] @@ -48,6 +48,54 @@ func ValidatePersistedProviderNames(cfg FileConfig) error { return nil } +// RepairUnnamedProvider gives legacy provider rows that predate required names +// an explicit persisted identity. Older releases resolved one unnamed row as +// activeProvider, falling back to "openai"; preserve that choice unless the +// user supplies a replacement. Multiple unnamed rows are left untouched because +// selecting one would silently merge or discard profiles. +func RepairUnnamedProvider(path string, replacement string) (FileConfig, error) { + path = strings.TrimSpace(path) + if path == "" { + return FileConfig{}, fmt.Errorf("config path is required") + } + data, err := os.ReadFile(path) + if err != nil { + return FileConfig{}, fmt.Errorf("read config %s: %w", path, err) + } + var cfg FileConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) + } + unnamed := -1 + for index := range cfg.Providers { + if strings.TrimSpace(cfg.Providers[index].Name) != "" { + continue + } + if unnamed >= 0 { + return FileConfig{}, fmt.Errorf("multiple unnamed persisted providers require manual repair in config.json") + } + unnamed = index + } + if unnamed < 0 { + return FileConfig{}, fmt.Errorf("no unnamed persisted provider found") + } + name := strings.TrimSpace(replacement) + if name == "" { + name = strings.TrimSpace(cfg.ActiveProvider) + } + if name == "" { + name = "openai" + } + cfg.Providers[unnamed].Name = name + if err := ValidatePersistedProviderNames(cfg); err != nil { + return FileConfig{}, err + } + if err := writeConfigFile(path, cfg); err != nil { + return FileConfig{}, err + } + return cfg, nil +} + // sameProviderIdentity reports whether two persisted spellings name the same // provider identity. It is credstore.NormalizeProvider — the credential store's // own rule — rather than strings.EqualFold, because the two disagree and the @@ -319,6 +367,9 @@ func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, erro } else if !os.IsNotExist(err) { return EnsuredProvider{}, fmt.Errorf("read config %s: %w", path, err) } + if err := ValidatePersistedProviderNames(cfg); err != nil { + return EnsuredProvider{}, err + } for _, provider := range cfg.Providers { // Which persisted row already serves this catalog entry is a provider // identity question, so it uses the credential store's rule. diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go index e8a146e0e..eb6687897 100644 --- a/internal/config/writer_test.go +++ b/internal/config/writer_test.go @@ -1298,6 +1298,85 @@ func TestValidatePersistedProviderNamesRejectsImplicitOpenAICollision(t *testing } } +func TestRepairUnnamedProviderPreservesLegacyNameResolution(t *testing.T) { + t.Run("active provider", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "work", + Providers: []ProviderProfile{{Name: " ", Model: "legacy-model"}}, + MaxTurns: 17, + }, 0o600) + cfg, err := RepairUnnamedProvider(path, "") + if err != nil { + t.Fatal(err) + } + if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "work" || cfg.Providers[0].Model != "legacy-model" || cfg.MaxTurns != 17 { + t.Fatalf("repaired config = %+v", cfg) + } + if err := ValidatePersistedProviderNames(cfg); err != nil { + t.Fatalf("repaired config remains invalid: %v", err) + } + }) + + t.Run("openai fallback", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Model: "gpt-4o"}}}, 0o600) + cfg, err := RepairUnnamedProvider(path, "") + if err != nil { + t.Fatal(err) + } + if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "openai" { + t.Fatalf("repaired config = %+v, want openai", cfg) + } + }) +} + +func TestRepairUnnamedProviderRejectsAmbiguousRepairWithoutWriting(t *testing.T) { + for name, cfg := range map[string]FileConfig{ + "name collision": {Providers: []ProviderProfile{{Name: ""}, {Name: "OPENAI"}}}, + "multiple unnamed": {Providers: []ProviderProfile{{Name: ""}, {Name: " "}}}, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + before := writeConfigFixture(t, path, cfg, 0o600) + if _, err := RepairUnnamedProvider(path, ""); err == nil { + t.Fatal("ambiguous repair succeeded") + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatalf("rejected repair changed config\nbefore: %s\nafter: %s", before, after) + } + }) + } +} + +func TestRepairUnnamedProviderAllowsExplicitUniqueName(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: ""}, {Name: "OPENAI"}}}, 0o600) + cfg, err := RepairUnnamedProvider(path, "legacy") + if err != nil { + t.Fatal(err) + } + if cfg.Providers[0].Name != "legacy" { + t.Fatalf("repaired name = %q, want legacy", cfg.Providers[0].Name) + } +} + +func TestEnsureCatalogProviderValidatesBeforeExistingProfileShortcut(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + before := writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: "xai"}, {Name: "XAI"}}}, 0o600) + if _, err := EnsureCatalogProvider(path, "xai"); err == nil || !strings.Contains(err.Error(), "ambiguous persisted provider names") { + t.Fatalf("EnsureCatalogProvider error = %v, want ambiguous config rejection", err) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(after, before) { + t.Fatalf("rejected ensure changed config: readErr=%v", err) + } +} + func TestResolvePersistedProviderNameBridgesIdentityToExactSpelling(t *testing.T) { cases := []struct { name string diff --git a/internal/oauth/manager.go b/internal/oauth/manager.go index dbdf7817e..d6d61ae57 100644 --- a/internal/oauth/manager.go +++ b/internal/oauth/manager.go @@ -32,6 +32,10 @@ type Manager struct { now func() time.Time buffer time.Duration out io.Writer + // beforeSave revalidates caller-owned state immediately before a completed + // login replaces a token. Interactive OAuth can take minutes, so validating + // only before it starts leaves a race where config becomes invalid mid-flow. + beforeSave func() error // openBrowser is invoked with the authorization URL for loopback logins. // Tests inject a function that drives the loopback redirect. openBrowser func(authURL string) error @@ -59,6 +63,10 @@ type ManagerOptions struct { RefreshBuffer time.Duration Out io.Writer OpenBrowser func(authURL string) error + // BeforeSave runs after authorization succeeds but before the token store is + // mutated. Login-only callers use it to fail closed when related config state + // changed during an interactive browser or device flow. + BeforeSave func() error } // NewManager builds a Manager, filling defaults. @@ -96,7 +104,7 @@ func NewManager(opts ManagerOptions) (*Manager, error) { } return &Manager{ store: opts.Store, registry: registry, client: client, - env: env, now: now, buffer: buffer, out: out, openBrowser: open, + env: env, now: now, buffer: buffer, out: out, openBrowser: open, beforeSave: opts.BeforeSave, }, nil } @@ -143,6 +151,11 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) (Status, error) } key := ProviderKey(opts.Provider) + if m.beforeSave != nil { + if err := m.beforeSave(); err != nil { + return Status{}, err + } + } if err := m.store.Save(key, token); err != nil { return Status{}, err } @@ -199,6 +212,11 @@ func (m *Manager) CompleteDeviceLogin(ctx context.Context, provider string, cfg return Status{}, err } key := ProviderKey(provider) + if m.beforeSave != nil { + if err := m.beforeSave(); err != nil { + return Status{}, err + } + } if err := m.store.Save(key, token); err != nil { return Status{}, err } diff --git a/internal/oauth/manager_test.go b/internal/oauth/manager_test.go index 219389940..6ddafaa65 100644 --- a/internal/oauth/manager_test.go +++ b/internal/oauth/manager_test.go @@ -293,3 +293,34 @@ func TestManagerLogout(t *testing.T) { t.Fatal("second logout should report nothing removed") } } + +func TestCompleteDeviceLoginBeforeSaveFailurePreservesPreviousToken(t *testing.T) { + fp := newFakeProvider(t, `{"access_token":"replacement","refresh_token":"replacement-refresh","expires_in":3600}`) + store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth.json")}) + if err != nil { + t.Fatal(err) + } + key := ProviderKey("demo") + previous := Token{AccessToken: "previous", RefreshToken: "previous-refresh"} + if err := store.Save(key, previous); err != nil { + t.Fatal(err) + } + wantErr := errors.New("config changed during login") + manager, err := NewManager(ManagerOptions{ + Store: store, + HTTPClient: fp.server.Client(), + BeforeSave: func() error { return wantErr }, + }) + if err != nil { + t.Fatal(err) + } + auth := DeviceAuth{DeviceCode: "device", ExpiresAt: time.Now().Add(time.Minute), Interval: time.Millisecond} + _, err = manager.CompleteDeviceLogin(context.Background(), "demo", Config{TokenEndpoint: fp.server.URL + "/token", ClientID: "client"}, auth) + if !errors.Is(err, wantErr) { + t.Fatalf("CompleteDeviceLogin error = %v, want pre-save rejection", err) + } + stored, ok, err := store.Load(key) + if err != nil || !ok || stored.AccessToken != previous.AccessToken || stored.RefreshToken != previous.RefreshToken { + t.Fatalf("pre-save rejection changed token: ok=%v err=%v token=%+v", ok, err, stored) + } +} diff --git a/internal/tui/oauth_device.go b/internal/tui/oauth_device.go index 00bdb557c..457e2ec0b 100644 --- a/internal/tui/oauth_device.go +++ b/internal/tui/oauth_device.go @@ -9,9 +9,17 @@ import ( "time" "github.com/Gitlawb/zero/internal/browser" + "github.com/Gitlawb/zero/internal/config" "github.com/Gitlawb/zero/internal/oauth" ) +func preflightOAuthLogin(configPath string) error { + if strings.TrimSpace(configPath) == "" { + return nil + } + return config.PreflightUserConfig(configPath) +} + // oauthPreferDeviceFlow reports whether the device-code flow should be the // default for a device-capable provider because no usable browser is likely // present (SSH session or a headless Linux box). On a desktop the browser flow @@ -59,7 +67,10 @@ func oauthDevicePrepare(name string) (oauth.DeviceAuth, oauth.Config, error) { // oauthDeviceComplete polls for the token authorized via oauthDevicePrepare and // stores it under provider: (phase 2). The runtime resolver then attaches // the refreshable token to model calls. -func oauthDeviceComplete(name string, cfg oauth.Config, auth oauth.DeviceAuth) error { +func oauthDeviceComplete(configPath string, name string, cfg oauth.Config, auth oauth.DeviceAuth) error { + if err := preflightOAuthLogin(configPath); err != nil { + return err + } store, err := oauth.NewStore(oauth.StoreOptions{}) if err != nil { return err @@ -68,6 +79,7 @@ func oauthDeviceComplete(name string, cfg oauth.Config, auth oauth.DeviceAuth) e Store: store, HTTPClient: &http.Client{Timeout: 60 * time.Second}, AllowPresets: true, // preset config is needed to poll/exchange the device token + BeforeSave: func() error { return preflightOAuthLogin(configPath) }, }) if err != nil { return err diff --git a/internal/tui/onboarding.go b/internal/tui/onboarding.go index ed09f3a87..b1f14b41b 100644 --- a/internal/tui/onboarding.go +++ b/internal/tui/onboarding.go @@ -537,10 +537,13 @@ func (m *model) moveSetupMethod(delta int) { // setupOAuthCmd runs the chosen provider's browser OAuth login off the UI // goroutine for first-run setup. Mirrors the /provider wizard's flow. -func setupOAuthCmd(provider providercatalog.Descriptor) tea.Cmd { +func setupOAuthCmd(provider providercatalog.Descriptor, configPath string) tea.Cmd { switch { case provider.OAuthMintsKey: return func() tea.Msg { + if err := preflightOAuthLogin(configPath); err != nil { + return setupOAuthMsg{providerID: provider.ID, err: err} + } key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{ OpenBrowser: browser.OpenURL, Timeout: 3 * time.Minute, @@ -549,13 +552,13 @@ func setupOAuthCmd(provider providercatalog.Descriptor) tea.Cmd { } case provider.ID == "chatgpt": return func() tea.Msg { - err := runProviderChatGPTLogin() + err := runProviderChatGPTLogin(configPath) return setupOAuthMsg{tokenLogin: true, providerID: provider.ID, err: err} } default: name := provider.ID return func() tea.Msg { - return setupOAuthMsg{tokenLogin: true, providerID: name, err: runProviderTokenLogin(name)} + return setupOAuthMsg{tokenLogin: true, providerID: name, err: runProviderTokenLogin(configPath, name)} } } } @@ -571,8 +574,11 @@ type setupOAuthDeviceMsg struct { err error } -func setupDevicePrepareCmd(name string) tea.Cmd { +func setupDevicePrepareCmd(configPath string, name string) tea.Cmd { return func() tea.Msg { + if err := preflightOAuthLogin(configPath); err != nil { + return setupOAuthDeviceMsg{providerID: name, err: err} + } auth, cfg, err := oauthDevicePrepare(name) if err != nil { return setupOAuthDeviceMsg{providerID: name, err: err} @@ -587,9 +593,9 @@ func setupDevicePrepareCmd(name string) tea.Cmd { } } -func setupDevicePollCmd(name string, cfg oauth.Config, auth oauth.DeviceAuth) tea.Cmd { +func setupDevicePollCmd(configPath string, name string, cfg oauth.Config, auth oauth.DeviceAuth) tea.Cmd { return func() tea.Msg { - return setupOAuthMsg{tokenLogin: true, providerID: name, err: oauthDeviceComplete(name, cfg, auth)} + return setupOAuthMsg{tokenLogin: true, providerID: name, err: oauthDeviceComplete(configPath, name, cfg, auth)} } } @@ -604,7 +610,7 @@ func (m model) startSetupDeviceLogin(descriptor providercatalog.Descriptor) (tea m.setup.oauthErr = "" m.setup.deviceUserCode = "" m.setup.deviceVerificationURI = "" - return m, setupDevicePrepareCmd(descriptor.ID) + return m, setupDevicePrepareCmd(m.setup.configPath, descriptor.ID) } // applySetupOAuthDeviceCode handles phase 1 of device-code login: show the code, @@ -626,7 +632,7 @@ func (m model) applySetupOAuthDeviceCode(msg setupOAuthDeviceMsg) (tea.Model, te } m.setup.deviceUserCode = msg.userCode m.setup.deviceVerificationURI = msg.verifyURL - return m, setupDevicePollCmd(msg.providerID, msg.cfg, msg.auth) + return m, setupDevicePollCmd(m.setup.configPath, msg.providerID, msg.cfg, msg.auth) } // applySetupOAuth folds an OAuth login result into the first-run setup: on success @@ -790,7 +796,7 @@ func (m model) advanceSetup() (tea.Model, tea.Cmd) { m.setup.oauthPending = true m.setup.oauthDevice = false m.setup.oauthErr = "" - return m, setupOAuthCmd(descriptor) + return m, setupOAuthCmd(descriptor, m.setup.configPath) } } if m.setup.stage == setupStageProvider { diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index cc5a2e9e6..3d39ee7f3 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -137,7 +137,7 @@ func (m model) applyProviderWizardDeviceCode(msg providerWizardDeviceCodeMsg) (m } m.providerWizard.deviceUserCode = msg.userCode m.providerWizard.deviceVerificationURI = msg.verifyURL - return m, providerWizardDevicePollCmd(msg.providerID, msg.attemptID, msg.cfg, msg.auth) + return m, providerWizardDevicePollCmd(m.userConfigPath, msg.providerID, msg.attemptID, msg.cfg, msg.auth) } // providerWizardSupportsOAuth reports whether the credential step should offer a @@ -155,11 +155,14 @@ func providerWizardSupportsOAuth(provider providercatalog.Descriptor) bool { // from the ID token and stores it on the saved token so the Codex provider can // inject it as a header on every request; other OAuth providers (xAI) run the // generic engine login which stores a refreshable token. -func providerWizardOAuthCmdFor(provider providercatalog.Descriptor, attemptID int) tea.Cmd { +func providerWizardOAuthCmdFor(provider providercatalog.Descriptor, attemptID int, configPath string) tea.Cmd { providerID := provider.ID switch { case provider.OAuthMintsKey: return func() tea.Msg { + if err := preflightOAuthLogin(configPath); err != nil { + return providerWizardOAuthMsg{providerID: providerID, attemptID: attemptID, err: err} + } key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{ OpenBrowser: browser.OpenURL, Timeout: 3 * time.Minute, @@ -168,12 +171,12 @@ func providerWizardOAuthCmdFor(provider providercatalog.Descriptor, attemptID in } case providerID == "chatgpt": return func() tea.Msg { - err := runProviderChatGPTLogin() + err := runProviderChatGPTLogin(configPath) return providerWizardOAuthMsg{providerID: providerID, attemptID: attemptID, tokenLogin: true, err: err} } default: return func() tea.Msg { - return providerWizardOAuthMsg{providerID: providerID, attemptID: attemptID, tokenLogin: true, err: runProviderTokenLogin(providerID)} + return providerWizardOAuthMsg{providerID: providerID, attemptID: attemptID, tokenLogin: true, err: runProviderTokenLogin(configPath, providerID)} } } } @@ -183,7 +186,10 @@ func providerWizardOAuthCmdFor(provider providercatalog.Descriptor, attemptID in // the token's Account field) and persists the resulting token via the oauth // store. The runtime resolver then attaches the bearer to Codex calls and the // Codex provider reads the Account field for the `chatgpt-account-id` header. -func runProviderChatGPTLogin() error { +func runProviderChatGPTLogin(configPath string) error { + if err := preflightOAuthLogin(configPath); err != nil { + return err + } env := buildOAuthPresetEnv() token, err := provideroauth.ChatGPTLogin(context.Background(), provideroauth.ChatGPTOptions{ Env: env, @@ -194,6 +200,9 @@ func runProviderChatGPTLogin() error { if err != nil { return err } + if err := preflightOAuthLogin(configPath); err != nil { + return err + } store, err := oauth.NewStore(oauth.StoreOptions{}) if err != nil { return err @@ -262,7 +271,10 @@ func buildOAuthPresetEnv() map[string]string { // runProviderTokenLogin runs the generic OAuth engine login for a provider that // has a built-in preset (e.g. xAI), storing a refreshable token under // provider:. The runtime resolver then attaches it to model calls. -func runProviderTokenLogin(name string) error { +func runProviderTokenLogin(configPath string, name string) error { + if err := preflightOAuthLogin(configPath); err != nil { + return err + } store, err := oauth.NewStore(oauth.StoreOptions{}) if err != nil { return err @@ -275,6 +287,7 @@ func runProviderTokenLogin(name string) error { // into its baked-in preset (e.g. xAI's public client_id); without this the // config never resolves and the browser never opens. AllowPresets: true, + BeforeSave: func() error { return preflightOAuthLogin(configPath) }, }) if err != nil { return err @@ -299,8 +312,11 @@ type providerWizardDeviceCodeMsg struct { // providerWizardDevicePrepareCmd runs phase 1 of the device-code login off the UI // goroutine and reports the code to display (or an error). -func providerWizardDevicePrepareCmd(name string, attemptID int) tea.Cmd { +func providerWizardDevicePrepareCmd(configPath string, name string, attemptID int) tea.Cmd { return func() tea.Msg { + if err := preflightOAuthLogin(configPath); err != nil { + return providerWizardDeviceCodeMsg{providerID: name, attemptID: attemptID, err: err} + } auth, cfg, err := oauthDevicePrepare(name) if err != nil { return providerWizardDeviceCodeMsg{providerID: name, attemptID: attemptID, err: err} @@ -318,9 +334,9 @@ func providerWizardDevicePrepareCmd(name string, attemptID int) tea.Cmd { // providerWizardDevicePollCmd runs phase 2 (poll for the token + store) off the // UI goroutine and reports completion as a regular OAuth result. -func providerWizardDevicePollCmd(name string, attemptID int, cfg oauth.Config, auth oauth.DeviceAuth) tea.Cmd { +func providerWizardDevicePollCmd(configPath string, name string, attemptID int, cfg oauth.Config, auth oauth.DeviceAuth) tea.Cmd { return func() tea.Msg { - return providerWizardOAuthMsg{providerID: name, attemptID: attemptID, tokenLogin: true, err: oauthDeviceComplete(name, cfg, auth)} + return providerWizardOAuthMsg{providerID: name, attemptID: attemptID, tokenLogin: true, err: oauthDeviceComplete(configPath, name, cfg, auth)} } } @@ -332,7 +348,7 @@ func (m model) startProviderDeviceLogin() (model, tea.Cmd) { return m, nil } attemptID := m.providerWizard.beginOAuthAttempt(true) - return m, providerWizardDevicePrepareCmd(provider.ID, attemptID) + return m, providerWizardDevicePrepareCmd(m.userConfigPath, provider.ID, attemptID) } const maxProviderWizardProvidersVisible = 10 @@ -974,7 +990,7 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) { if providerWizardSupportsOAuth(m.providerWizard.currentProvider()) { provider := m.providerWizard.currentProvider() attemptID := m.providerWizard.beginOAuthAttempt(false) - return m, providerWizardOAuthCmdFor(provider, attemptID) + return m, providerWizardOAuthCmdFor(provider, attemptID, m.userConfigPath) } return m, nil case keyText(msg) != "": diff --git a/internal/tui/provider_wizard_discovery.go b/internal/tui/provider_wizard_discovery.go index 1ff8a51ee..41d89e1da 100644 --- a/internal/tui/provider_wizard_discovery.go +++ b/internal/tui/provider_wizard_discovery.go @@ -48,7 +48,7 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) { return m.startProviderDeviceLogin() } attemptID := m.providerWizard.beginOAuthAttempt(false) - return m, providerWizardOAuthCmdFor(provider, attemptID) + return m, providerWizardOAuthCmdFor(provider, attemptID, m.userConfigPath) } // A non-OAuth provider that already has a key in the credential store: offer // keep/replace/remove before re-entering credentials. diff --git a/internal/tui/provider_wizard_oauth_test.go b/internal/tui/provider_wizard_oauth_test.go index bd4ac4d14..ffae32c20 100644 --- a/internal/tui/provider_wizard_oauth_test.go +++ b/internal/tui/provider_wizard_oauth_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/oauth" "github.com/Gitlawb/zero/internal/providercatalog" ) @@ -420,6 +421,51 @@ func TestPersistOAuthLoginProviderWritesKeylessProfileWithoutStealingActive(t *t persistOAuthLoginProvider("", "chatgpt") } +func TestOAuthCommandsRejectInvalidConfigBeforeCredentialSideEffects(t *testing.T) { + t.Setenv("ZERO_OAUTH_STORAGE", "file") + t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(t.TempDir(), "oauth.json")) + configPath := filepath.Join(t.TempDir(), "config.json") + seed := []byte(`{"providers":[{"name":"xai"},{"name":"XAI"}]}`) + if err := os.WriteFile(configPath, seed, 0o600); err != nil { + t.Fatal(err) + } + store, err := oauth.NewStore(oauth.StoreOptions{}) + if err != nil { + t.Fatal(err) + } + previous := oauth.Token{AccessToken: "previous-access", RefreshToken: "previous-refresh"} + if err := store.Save(oauth.ProviderKey("xai"), previous); err != nil { + t.Fatal(err) + } + + for _, providerID := range []string{"openrouter", "chatgpt", "xai"} { + descriptor, ok := providercatalog.Get(providerID) + if !ok { + t.Fatalf("missing catalog provider %q", providerID) + } + msg, ok := providerWizardOAuthCmdFor(descriptor, 7, configPath)().(providerWizardOAuthMsg) + if !ok || msg.err == nil || !strings.Contains(msg.err.Error(), "ambiguous persisted provider names") { + t.Fatalf("wizard OAuth %s did not preflight: %#v", providerID, msg) + } + setupMsg, ok := setupOAuthCmd(descriptor, configPath)().(setupOAuthMsg) + if !ok || setupMsg.err == nil || !strings.Contains(setupMsg.err.Error(), "ambiguous persisted provider names") { + t.Fatalf("setup OAuth %s did not preflight: %#v", providerID, setupMsg) + } + } + deviceMsg, ok := providerWizardDevicePollCmd(configPath, "xai", 8, oauth.Config{}, oauth.DeviceAuth{})().(providerWizardOAuthMsg) + if !ok || deviceMsg.err == nil || !strings.Contains(deviceMsg.err.Error(), "ambiguous persisted provider names") { + t.Fatalf("device completion did not revalidate config: %#v", deviceMsg) + } + stored, ok, err := store.Load(oauth.ProviderKey("xai")) + if err != nil || !ok || stored.AccessToken != previous.AccessToken || stored.RefreshToken != previous.RefreshToken { + t.Fatalf("rejected TUI login changed previous token: ok=%v err=%v", ok, err) + } + after, err := os.ReadFile(configPath) + if err != nil || string(after) != string(seed) { + t.Fatalf("rejected TUI login changed config: readErr=%v", err) + } +} + func TestAppendOAuthLoginProfileAddsOnceAndRespectsRenames(t *testing.T) { saved := []config.ProviderProfile{{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible}} From 89b0c6e0d5524f4f3f02539e70e60b109d36c4aa Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 21 Aug 2026 03:43:01 +0200 Subject: [PATCH 12/17] docs(provider): document config repair and OAuth validation Amp-Thread-ID: https://ampcode.com/threads/T-01a020b3-4e7a-732b-aef1-b6fafd87b569 Co-authored-by: Amp --- CHANGELOG.md | 11 ++++++++++- README.md | 8 +++++++- README_ZH.md | 8 +++++++- docs/oauth-subscriptions.md | 7 +++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfe4fea8..aed6f41dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is tagged. Until then, source builds report the version `dev`. +## Unreleased + +### Features + +* **providers:** add `zero providers repair-config [--name ]` to recover a single legacy unnamed provider profile while preserving the active-provider name or falling back to `openai` + +### Bug Fixes + +* **oauth:** validate provider configuration before authorization and immediately before token replacement across CLI, TUI, setup, and device flows, preserving existing credentials when validation fails + ## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21) @@ -26,7 +36,6 @@ tagged. Until then, source builds report the version `dev`. * **modelregistry:** expose reasoning effort for DeepSeek V4 models ([#931](https://github.com/Gitlawb/zero/issues/931)) ([90dcfd1](https://github.com/Gitlawb/zero/commit/90dcfd127e8a6d9902ec9f4e72f6d03fef1a0fc6)) * **providers:** discover ChatGPT capabilities ([#890](https://github.com/Gitlawb/zero/issues/890)) ([2d2450e](https://github.com/Gitlawb/zero/commit/2d2450e9a744349f0d01b1d4e9ba29c24ba5650d)) * **sandbox:** normalize launcher names before the command-prefix denylist ([#934](https://github.com/Gitlawb/zero/issues/934)) ([6edf9a8](https://github.com/Gitlawb/zero/commit/6edf9a8b78dc030dc44598919db1c7fa9d4f809a)) - ## [0.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10) diff --git a/README.md b/README.md index 3d3590814..91c6749c1 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,12 @@ zero models list zero doctor ``` +If an upgraded `config.json` contains one legacy provider profile without a +name, repair it with `zero providers repair-config`. The command preserves the +saved `activeProvider` name (falling back to `openai`), or accepts an explicit +replacement with `--name `. Multiple unnamed rows are not guessed; repair +those directly in `config.json`. + For API providers, set the matching environment variable before setup or enter the key in the wizard: @@ -290,7 +296,7 @@ zero exec one-shot or scripted agent run zero setup first-run provider setup zero auth OAuth/login helpers for supported providers zero models model registry and capabilities -zero providers provider profiles and detection +zero providers provider profiles, recovery, and detection zero doctor setup, key, and connectivity checks zero context context-budget report zero repo-map deterministic repository map diff --git a/README_ZH.md b/README_ZH.md index ff5186a6d..5611e4ab1 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -99,6 +99,12 @@ zero models list zero doctor ``` +如果升级后的 `config.json` 中有一个旧版未命名的提供商配置,请运行 +`zero providers repair-config` 进行修复。该命令会保留已保存的 +`activeProvider` 名称(未设置时回退到 `openai`),也可以通过 +`--name <名称>` 显式指定新名称。对于多个未命名的配置行,Zero 不会猜测, +请直接在 `config.json` 中修复。 + 对于 API 提供商,在设置之前设置匹配的环境变量或在向导中输入密钥: ```bash @@ -208,7 +214,7 @@ zero exec 一次性或脚本化智能体运行 zero setup 首次运行提供商设置 zero auth 支持提供商的 OAuth/登录辅助 zero models 模型注册表和能力 -zero providers 提供商配置和检测 +zero providers 提供商配置、修复和检测 zero doctor 设置、密钥和连接检查 zero context 上下文预算报告 zero repo-map 确定性仓库映射 diff --git a/docs/oauth-subscriptions.md b/docs/oauth-subscriptions.md index 6bb70c98d..6c89897b8 100644 --- a/docs/oauth-subscriptions.md +++ b/docs/oauth-subscriptions.md @@ -32,6 +32,13 @@ When a login exists for a provider, the **OpenAI and Anthropic** providers send before. Tokens are stored 0600 (or the OS keyring with `ZERO_OAUTH_STORAGE=keyring`) and never logged. See `zero auth --help`. +Provider OAuth login validates the persisted user configuration before opening +authorization and revalidates it immediately before replacing a stored token. +This applies to CLI login, the TUI/setup wizard, and device-code completion. If +the configuration is invalid at either check, Zero aborts without overwriting +the previous OAuth credential. If the error identifies one legacy unnamed +provider profile, repair it with `zero providers repair-config`. + ### In the setup wizard (`/provider`) Running `/provider` opens a **"How do you want to connect?"** chooser: From 72d3ea63b8aadcff6a5005c746bfe5b421a26184 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 21 Aug 2026 16:11:09 +0200 Subject: [PATCH 13/17] fix(provider): make legacy config repairs composable Amp-Thread-ID: https://ampcode.com/threads/T-01a0246b-e61a-70e8-aa71-24f1ea7804c8 Co-authored-by: Amp --- internal/cli/app_test.go | 21 +++++++++ internal/cli/observability.go | 7 +++ internal/cli/provider_onboarding_test.go | 23 +++++++++ internal/config/writer.go | 59 ++++++++++++++++++++++-- internal/config/writer_test.go | 37 +++++++++++++-- internal/doctor/doctor.go | 26 ++++++++--- internal/doctor/doctor_test.go | 14 ++++++ 7 files changed, 171 insertions(+), 16 deletions(-) diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index aa9faaf14..fc7eb9494 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -403,6 +403,27 @@ func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) { } } +func TestRunNoArgsOffersRepairCommandForPersistedNameFailure(t *testing.T) { + var stdout, stderr bytes.Buffer + cwd := t.TempDir() + configPath := filepath.Join(t.TempDir(), "zero", "config.json") + writeProviderOnboardingConfig(t, configPath, config.FileConfig{Providers: []config.ProviderProfile{{Name: ""}, {Name: "work"}, {Name: "WORK"}}}) + exitCode := runWithDeps(nil, &stdout, &stderr, appDeps{ + getwd: func() (string, error) { return cwd, nil }, + userConfigPath: func() (string, error) { return configPath, nil }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { + return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}}) + }, + runTUI: func(context.Context, tui.Options) int { + t.Fatal("TUI must not launch with ambiguous persisted identities") + return 0 + }, + }) + if exitCode == exitSuccess || !strings.Contains(stderr.String(), "zero providers repair-config") { + t.Fatalf("exit=%d stderr=%q, want actionable repair path", exitCode, stderr.String()) + } +} + func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/cli/observability.go b/internal/cli/observability.go index c361d24e0..197142ba4 100644 --- a/internal/cli/observability.go +++ b/internal/cli/observability.go @@ -44,12 +44,18 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) userConfig = resolveOptions.UserConfigPath projectConfig = resolveOptions.ProjectConfigPath } + if path, pathErr := deps.userConfigPath(); pathErr == nil { + userConfig = path + } var provider config.ProviderProfile var sandboxConfig config.SandboxConfig + var configResolveErr error if resolved, resolveErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); resolveErr == nil { provider = resolved.Provider sandboxConfig = resolved.Sandbox + } else { + configResolveErr = resolveErr } var health *providerhealth.Result if options.connectivity && config.HasProviderProfile(provider) { @@ -69,6 +75,7 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) UserConfig: userConfig, ProjectConfig: projectConfig, Provider: provider, + ResolveError: configResolveErr, WorkspaceRoot: workspaceRoot, Sandbox: sandboxConfig, Connectivity: options.connectivity, diff --git a/internal/cli/provider_onboarding_test.go b/internal/cli/provider_onboarding_test.go index 72737160d..0105339aa 100644 --- a/internal/cli/provider_onboarding_test.go +++ b/internal/cli/provider_onboarding_test.go @@ -89,6 +89,29 @@ func TestRunProvidersRepairConfigRecoversLegacyUnnamedProvider(t *testing.T) { } } +func TestProviderRepairCommandsCanResolveIndependentLegacyNameProblems(t *testing.T) { + var stdout, stderr bytes.Buffer + configPath := filepath.Join(t.TempDir(), "zero", "config.json") + writeProviderOnboardingConfig(t, configPath, config.FileConfig{Providers: []config.ProviderProfile{ + {Name: ""}, {Name: "work"}, {Name: "WORK"}, + }}) + deps := providerSetupDeps(configPath) + if code := runWithDeps([]string{"providers", "repair-config", "--name", "legacy"}, &stdout, &stderr, deps); code != exitSuccess { + t.Fatalf("repair-config exit=%d stderr=%q", code, stderr.String()) + } + if err := config.ValidatePersistedProviderNames(readFileConfig(t, configPath)); err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("first repair should leave only the independent duplicate issue, got %v", err) + } + stdout.Reset() + stderr.Reset() + if code := runWithDeps([]string{"providers", "remove", "WORK"}, &stdout, &stderr, deps); code != exitSuccess { + t.Fatalf("remove exit=%d stderr=%q", code, stderr.String()) + } + if err := config.ValidatePersistedProviderNames(readFileConfig(t, configPath)); err != nil { + t.Fatalf("final config remains invalid: %v", err) + } +} + func TestRunProvidersUseJSONIncludesActiveProviderAndConfigPath(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/config/writer.go b/internal/config/writer.go index 2316cbf69..6f24d9184 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -48,6 +48,54 @@ func ValidatePersistedProviderNames(cfg FileConfig) error { return nil } +// persistedProviderNameProblems returns independently repairable persisted-name +// problems. Keys are stable identities so a repair can prove it reduced an +// existing problem without introducing or worsening another one. +func persistedProviderNameProblems(cfg FileConfig) map[string]int { + problems := map[string]int{} + seen := map[string]int{} + for _, provider := range cfg.Providers { + name := strings.TrimSpace(provider.Name) + if name == "" { + problems["unnamed"]++ + continue + } + seen[credstore.NormalizeProvider(name)]++ + } + for identity, count := range seen { + if count > 1 { + problems["duplicate:"+identity] = count - 1 + } + } + return problems +} + +func writeProviderNameRepair(path string, before FileConfig, after FileConfig) error { + oldProblems := persistedProviderNameProblems(before) + if len(oldProblems) == 0 { + return writeConfigFile(path, after) + } + newProblems := persistedProviderNameProblems(after) + oldTotal, newTotal := 0, 0 + for _, count := range oldProblems { + oldTotal += count + } + for problem, count := range newProblems { + newTotal += count + if count > oldProblems[problem] { + return ValidatePersistedProviderNames(after) + } + } + if newTotal >= oldTotal { + return ValidatePersistedProviderNames(after) + } + data, err := json.MarshalIndent(after, "", " ") + if err != nil { + return fmt.Errorf("encode config JSON: %w", err) + } + return writeConfigData(path, data) +} + // RepairUnnamedProvider gives legacy provider rows that predate required names // an explicit persisted identity. Older releases resolved one unnamed row as // activeProvider, falling back to "openai"; preserve that choice unless the @@ -87,10 +135,11 @@ func RepairUnnamedProvider(path string, replacement string) (FileConfig, error) name = "openai" } cfg.Providers[unnamed].Name = name - if err := ValidatePersistedProviderNames(cfg); err != nil { - return FileConfig{}, err + var before FileConfig + if err := json.Unmarshal(data, &before); err != nil { + return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) } - if err := writeConfigFile(path, cfg); err != nil { + if err := writeProviderNameRepair(path, before, cfg); err != nil { return FileConfig{}, err } return cfg, nil @@ -515,6 +564,8 @@ func RemoveProvider(path string, name string) (FileConfig, error) { if err := json.Unmarshal(data, &cfg); err != nil { return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) } + before := cfg + before.Providers = append([]ProviderProfile(nil), cfg.Providers...) // Persisted provider identity is exact. Resolution may fold names from // runtime sources, but config mutations must target the requested row. This @@ -561,7 +612,7 @@ func RemoveProvider(path string, name string) (FileConfig, error) { cfg.ActiveProvider = resolved } } - if err := writeConfigFile(path, cfg); err != nil { + if err := writeProviderNameRepair(path, before, cfg); err != nil { return FileConfig{}, err } return cfg, nil diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go index eb6687897..218eaa525 100644 --- a/internal/config/writer_test.go +++ b/internal/config/writer_test.go @@ -1186,16 +1186,43 @@ func TestRemoveProviderRejectsNonExactCaseDuplicateTarget(t *testing.T) { } } -func TestRemoveProviderRejectsRepairThatRemainsAmbiguous(t *testing.T) { +func TestRemoveProviderPublishesRepairThatReducesRemainingAmbiguity(t *testing.T) { path := filepath.Join(t.TempDir(), "zero.json") - before := writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: "work"}, {Name: "WORK"}, {Name: "Work"}}}, 0o600) - _, err := RemoveProvider(path, "Work") + writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: "work"}, {Name: "WORK"}, {Name: "Work"}}}, 0o600) + cfg, err := RemoveProvider(path, "Work") + if err != nil { + t.Fatalf("strictly reducing repair failed: %v", err) + } + if len(cfg.Providers) != 2 || cfg.Providers[0].Name != "work" || cfg.Providers[1].Name != "WORK" { + t.Fatalf("repaired config = %+v", cfg) + } +} + +func TestRemoveProviderPublishesRepairWhileUnnamedProblemRemains(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: ""}, {Name: "work"}, {Name: "WORK"}}}, 0o600) + cfg, err := RemoveProvider(path, "WORK") + if err != nil { + t.Fatalf("exact duplicate repair failed while an unnamed row remained: %v", err) + } + if len(cfg.Providers) != 2 || cfg.Providers[0].Name != "" || cfg.Providers[1].Name != "work" { + t.Fatalf("repaired config = %+v", cfg) + } + if err := ValidatePersistedProviderNames(cfg); err == nil || !strings.Contains(err.Error(), "cannot be empty") { + t.Fatalf("repair should leave only the independent unnamed-row problem, got %v", err) + } +} + +func TestRepairUnnamedProviderRejectsRepairThatIntroducesDuplicate(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + before := writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: ""}, {Name: "work"}}}, 0o600) + _, err := RepairUnnamedProvider(path, "WORK") if err == nil || !strings.Contains(err.Error(), "ambiguous persisted provider names") { - t.Fatalf("error = %v, want resulting-config validation error", err) + t.Fatalf("error = %v, want newly introduced duplicate rejection", err) } after, readErr := os.ReadFile(path) if readErr != nil || !bytes.Equal(after, before) { - t.Fatalf("invalid repair rewrote config: readErr=%v", readErr) + t.Fatalf("rejected repair rewrote config: readErr=%v", readErr) } } diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index b6f222573..d40cb30a2 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -47,6 +47,7 @@ type Options struct { UserConfig string ProjectConfig string Provider config.ProviderProfile + ResolveError error WorkspaceRoot string Sandbox config.SandboxConfig Connectivity bool @@ -70,7 +71,7 @@ func Run(options Options) Report { configFilesCheck(options.UserConfig, options.ProjectConfig), configValidationCheck(options.UserConfig, options.ProjectConfig), } - providerCheck := providerConfigCheck(options.Provider) + providerCheck := providerConfigCheck(options.Provider, options.ResolveError) checks = append(checks, providerCheck) modelCheck := providerModelCheck(options.Provider) checks = append(checks, modelCheck) @@ -137,7 +138,10 @@ func configFilesCheck(userPath string, projectPath string) Check { return check("config.files", "Config files", StatusPass, "Zero config file inputs are available for inspection.", details) } -func providerConfigCheck(profile config.ProviderProfile) Check { +func providerConfigCheck(profile config.ProviderProfile, resolveErrors ...error) Check { + if len(resolveErrors) > 0 && resolveErrors[0] != nil { + return check("provider.config", "Provider config", StatusFail, "Provider config could not be resolved: "+resolveErrors[0].Error(), map[string]any{"help": "Follow the repair command in the error, then run `zero doctor` again."}) + } if emptyProviderProfile(profile) { return check("provider.config", "Provider config", StatusFail, "No LLM provider is configured.", map[string]any{"help": "Set a provider in config or environment."}) } @@ -410,16 +414,24 @@ func configValidationCheck(userPath string, projectPath string) Check { continue } _, issues := config.ValidateBytes(data) - if len(issues) == 0 { - continue - } - messages := make([]string, 0, len(issues)) + messages := make([]string, 0, len(issues)+1) for _, issue := range issues { messages = append(messages, issue.Message) } + if path == userPath { + var persisted config.FileConfig + if err := json.Unmarshal(data, &persisted); err == nil { + if err := config.ValidatePersistedProviderNames(persisted); err != nil { + messages = append(messages, err.Error()) + } + } + } + if len(messages) == 0 { + continue + } details[path] = map[string]any{"issues": messages} status = StatusFail - issueCount += len(issues) + issueCount += len(messages) } if status == StatusPass { diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 4fecf761b..00f2937b5 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -1,6 +1,7 @@ package doctor import ( + "fmt" "os" "path/filepath" "strings" @@ -201,6 +202,19 @@ func TestConfigValidationCheckPassesForValidConfig(t *testing.T) { } } +func TestConfigValidationCheckReportsPersistedProviderNameRepair(t *testing.T) { + path := writeDoctorConfig(t, `{"providers":[{"name":""},{"name":"work"},{"name":"WORK"}]}`) + report := Run(Options{Runtime: "go", UserConfig: path, ResolveError: config.ValidatePersistedProviderNames(config.FileConfig{Providers: []config.ProviderProfile{{Name: ""}}})}) + check := report.Check("config.validation") + if check == nil || check.Status != StatusFail || !strings.Contains(fmt.Sprint(check.Details), "providers repair-config") { + t.Fatalf("persisted-name validation = %#v", check) + } + provider := report.Check("provider.config") + if provider == nil || !strings.Contains(provider.Message, "could not be resolved") || strings.Contains(provider.Message, "No LLM provider") { + t.Fatalf("provider resolution diagnostic = %#v", provider) + } +} + func TestConfigValidationCheckFailsMalformedJSONWithLineCol(t *testing.T) { // Unterminated object: the trailing comma + EOF yields a *json.SyntaxError // whose offset is the end of the 32-byte document (line 3, col 1). From f4a944912cfe2024a0d7feaa507d267c799df2d7 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 21 Aug 2026 17:53:21 +0200 Subject: [PATCH 14/17] fix(provider): use user-scoped credential store Amp-Thread-ID: https://ampcode.com/threads/T-01a0246b-e61a-70e8-aa71-24f1ea7804c8 Co-authored-by: Amp --- internal/cli/auth.go | 8 ++--- internal/cli/auth_test.go | 10 ++++-- internal/cli/provider_identity_matrix_test.go | 31 ++++++++++--------- internal/cli/provider_onboarding.go | 23 +++----------- internal/cli/provider_onboarding_test.go | 20 ++++++------ internal/config/credentials.go | 9 +++--- internal/config/credentials_test.go | 24 ++++++++++++-- 7 files changed, 67 insertions(+), 58 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index a93894519..4b4dbd689 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -479,14 +479,12 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe // credential (OAuth token AND key), not just the OAuth side. Surface deletion // failures rather than reporting success while a credential remains. // Marker first, then the secret: the reverse order leaves apiKeyStored:true - // with nothing behind it if the config write fails. Both halves address the - // store BESIDE the config being edited (where setup/rename captured the key), - // so a non-default config path cannot clear a marker here while the secret - // stays in the default-path store. + // with nothing behind it if the config write fails. Provider credentials are + // user-scoped, so deletion uses the same default user store as runtime lookup. if _, clearErr := config.ClearProviderKeyStoredCaseVariants(configPath, provider); clearErr != nil { return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash) } - keyRemoved, keyErr := removeStoredProviderKeyAt(configPath, provider) + keyRemoved, keyErr := config.ForgetProviderKey(provider) if keyErr != nil { return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash) } diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index dc30f5d38..9bb3fe20e 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -155,6 +155,8 @@ func TestRunAuthOpenRouterRejectsArgs(t *testing.T) { } func TestRunAuthOpenRouterSavesMintedKey(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCLIUserConfigRoot(t) configPath := filepath.Join(t.TempDir(), "config.json") var stdout, stderr bytes.Buffer @@ -179,7 +181,7 @@ func TestRunAuthOpenRouterSavesMintedKey(t *testing.T) { if profile.Name != "openrouter" || profile.CatalogID != "openrouter" || !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" { t.Fatalf("provider not stored-key sanitized: %#v", profile) } - store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) + store, err := config.ProviderKeyStore() if err != nil { t.Fatal(err) } @@ -506,13 +508,14 @@ func TestRunAuthLogoutRejectsConfigPathFailureBeforeCredentialDeletion(t *testin // rather than deleting the shared entry. func TestRunAuthOpenRouterPreservesExistingKeyWhenConfigRejected(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCLIUserConfigRoot(t) dir := t.TempDir() configPath := filepath.Join(dir, "config.json") seed := `{"activeProvider":"openrouter","providers":[{"name":"openrouter","apiKeyStored":true},{"name":"OPENROUTER","apiKeyStored":true}]}` if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil { t.Fatal(err) } - store, err := config.ProviderKeyStoreAt(dir) + store, err := config.ProviderKeyStore() if err != nil { t.Fatal(err) } @@ -560,13 +563,14 @@ func TestRunAuthOpenRouterPreservesExistingKeyWhenConfigRejected(t *testing.T) { // previously left apiKeyStored:true with no secret behind it. func TestRunAuthLogoutClearsMarkerForCaseVariantSpelling(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCLIUserConfigRoot(t) withAuthStore(t) dir := t.TempDir() configPath := filepath.Join(dir, "config.json") if err := os.WriteFile(configPath, []byte(`{"providers":[{"name":"work","apiKeyStored":true}]}`), 0o600); err != nil { t.Fatal(err) } - store, err := config.ProviderKeyStoreAt(dir) + store, err := config.ProviderKeyStore() if err != nil { t.Fatal(err) } diff --git a/internal/cli/provider_identity_matrix_test.go b/internal/cli/provider_identity_matrix_test.go index a05f2e0f8..fd3f3d633 100644 --- a/internal/cli/provider_identity_matrix_test.go +++ b/internal/cli/provider_identity_matrix_test.go @@ -10,28 +10,29 @@ import ( "github.com/Gitlawb/zero/internal/config" ) -// providerIdentityFixture seeds a config file plus the credential store beside -// it, and hands back the config path. Every row of the matrix below starts from -// one of these so the CLI command, the config writer, and the credential store -// all see the same on-disk world. +// providerIdentityFixture seeds a config file plus the user-scoped credential +// store and hands back the config path. Every row of the matrix below starts +// from one of these so CLI mutations and runtime credential lookup see the same +// store even when the injected config path is non-default. type providerIdentityFixture struct { // configJSON is written verbatim: these scenarios need spellings and // duplicate rows that FileConfig round-tripping would not preserve. configJSON string - // storedKeys are seeded into the credential store co-located with the config. + // storedKeys are seeded into the user-scoped credential store. storedKeys map[string]string } func seedProviderIdentityFixture(t *testing.T, fixture providerIdentityFixture) string { t.Helper() + setCLIUserConfigRoot(t) dir := t.TempDir() configPath := filepath.Join(dir, "config.json") if err := os.WriteFile(configPath, []byte(fixture.configJSON), 0o600); err != nil { t.Fatalf("seed config: %v", err) } if len(fixture.storedKeys) > 0 { - store, err := config.ProviderKeyStoreAt(dir) + store, err := config.ProviderKeyStore() if err != nil { t.Fatalf("open credential store: %v", err) } @@ -44,10 +45,10 @@ func seedProviderIdentityFixture(t *testing.T, fixture providerIdentityFixture) return configPath } -func storedProviderKey(t *testing.T, configPath string, provider string) (string, bool) { +func storedProviderKey(t *testing.T, provider string) (string, bool) { t.Helper() - store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) + store, err := config.ProviderKeyStore() if err != nil { t.Fatalf("open credential store: %v", err) } @@ -80,7 +81,7 @@ func TestProviderIdentityMatrix(t *testing.T) { if cfg := readFileConfig(t, configPath); len(cfg.Providers) != 0 { t.Fatalf("providers = %#v, want the row removed", cfg.Providers) } - if _, ok := storedProviderKey(t, configPath, "work"); ok { + if _, ok := storedProviderKey(t, "work"); ok { t.Fatal("stored key survived removal of its only owner") } }) @@ -122,7 +123,7 @@ func TestProviderIdentityMatrix(t *testing.T) { if string(after) != seed { t.Fatalf("rejected removal rewrote config:\n%s", after) } - if key, ok := storedProviderKey(t, configPath, "work"); !ok || key != "sk-work" { + if key, ok := storedProviderKey(t, "work"); !ok || key != "sk-work" { t.Fatalf("stored key does not match (present=%v, len=%d), want sk-work untouched", ok, len(key)) } }) @@ -141,12 +142,12 @@ func TestProviderIdentityMatrix(t *testing.T) { if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "WORK" || !cfg.Providers[0].APIKeyStored { t.Fatalf("config = %#v, want WORK surviving with its marker", cfg) } - key, ok := storedProviderKey(t, configPath, "WORK") + key, ok := storedProviderKey(t, "WORK") if !ok || key != "sk-shared" { t.Fatalf("stored key does not match (present=%v, len=%d), want the survivor's sk-shared kept", ok, len(key)) } // The survivor must actually be able to load it. - store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) + store, err := config.ProviderKeyStore() if err != nil { t.Fatal(err) } @@ -167,7 +168,7 @@ func TestProviderIdentityMatrix(t *testing.T) { } // The surviving WORK row never claimed the credential, so keeping the // secret would only orphan it behind a marker ApplyStoredAPIKey skips. - if _, ok := storedProviderKey(t, configPath, "WORK"); ok { + if _, ok := storedProviderKey(t, "WORK"); ok { t.Fatal("stored key was orphaned behind a markerless survivor") } if !strings.Contains(stdout.String(), "Deleted its stored API key.") { @@ -224,10 +225,10 @@ func TestProviderIdentityMatrix(t *testing.T) { if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "ſ" { t.Fatalf("config = %#v, want only the long-s row remaining", cfg) } - if key, ok := storedProviderKey(t, configPath, "ſ"); !ok || key != "sk-long" { + if key, ok := storedProviderKey(t, "ſ"); !ok || key != "sk-long" { t.Fatalf("long-s key does not match (present=%v, len=%d), want sk-long untouched", ok, len(key)) } - if _, ok := storedProviderKey(t, configPath, "s"); ok { + if _, ok := storedProviderKey(t, "s"); ok { t.Fatal("latin-s key survived removal of its only owner") } }) diff --git a/internal/cli/provider_onboarding.go b/internal/cli/provider_onboarding.go index 59e0b8e4e..c326b6507 100644 --- a/internal/cli/provider_onboarding.go +++ b/internal/cli/provider_onboarding.go @@ -3,7 +3,6 @@ package cli import ( "fmt" "io" - "path/filepath" "strconv" "strings" "unicode" @@ -510,14 +509,13 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps if err != nil { return writeAppError(stderr, err.Error(), exitCrash) } - // Delete the key from the store BESIDE the config being edited — the same - // store setup/rename write to — not the default-path store, so a - // non-default config path cannot leave the encrypted key behind. A surviving - // case variant that still claims the credential keeps it (see - // config.CredentialKeyRetained); a survivor that never claimed it does not. + // Provider credentials are user-scoped, so delete from the same default user + // store runtime lookup uses. A surviving case variant that still claims the + // credential keeps it (see config.CredentialKeyRetained); a survivor that + // never claimed it does not. keyRemoved, keyErr := false, error(nil) if !config.CredentialKeyRetained(cfg.Providers, name) { - keyRemoved, keyErr = removeStoredProviderKeyAt(configPath, name) + keyRemoved, keyErr = config.ForgetProviderKey(name) } if options.json { payload := map[string]any{ @@ -563,17 +561,6 @@ func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps return exitSuccess } -// removeStoredProviderKeyAt deletes a provider's API key from the credential -// store co-located with configPath (the store SecureProviderProfile captured -// it into and RenameProvider migrates within). -func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) { - store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath)) - if err != nil { - return false, err - } - return store.Delete(provider) -} - // runProvidersRename renames a saved provider profile, migrating its stored // API key and the activeProvider pointer along with it (config.RenameProvider). func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { diff --git a/internal/cli/provider_onboarding_test.go b/internal/cli/provider_onboarding_test.go index 0105339aa..f68a0e507 100644 --- a/internal/cli/provider_onboarding_test.go +++ b/internal/cli/provider_onboarding_test.go @@ -505,18 +505,18 @@ func writeProviderOnboardingConfig(t *testing.T, path string, cfg config.FileCon } } -// TestRunProvidersRemoveDeletesKeyBesideConfig: the stored key must be deleted -// from the credential store CO-LOCATED with the config being edited (where -// SecureProviderProfile captured it), not the default-path store. -func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) { +// Runtime provider credentials are user-scoped even when tests inject a +// non-default config path. +func TestRunProvidersRemoveDeletesKeyFromUserStore(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCLIUserConfigRoot(t) dir := t.TempDir() configPath := filepath.Join(dir, "config.json") seed := `{"activeProvider":"gw","providers":[{"name":"gw","provider_kind":"openai-compatible","baseURL":"https://gw.example.com/v1","apiKeyStored":true,"model":"m1"},{"name":"other","provider_kind":"openai-compatible","baseURL":"https://o.example.com/v1","model":"m2"}]}` if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil { t.Fatalf("seed config: %v", err) } - store, err := config.ProviderKeyStoreAt(dir) + store, err := config.ProviderKeyStore() if err != nil { t.Fatalf("open store: %v", err) } @@ -546,7 +546,7 @@ func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) { t.Fatalf("active must hand off, got %q", payload.ActiveProvider) } if _, ok, _ := store.Get("gw"); ok { - t.Fatalf("stored key must be deleted from the store beside the config") + t.Fatalf("stored key must be deleted from the user-scoped store") } } @@ -558,12 +558,13 @@ func TestRunProvidersRemoveFailsWhenStoredKeyCleanupFails(t *testing.T) { } t.Run(name, func(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "file") + userConfigRoot := setCLIUserConfigRoot(t) dir := t.TempDir() configPath := filepath.Join(dir, "config.json") if err := os.WriteFile(configPath, []byte(`{"providers":[{"name":"gw","apiKeyStored":true}]}`), 0o600); err != nil { t.Fatal(err) } - store, err := config.ProviderKeyStoreAt(dir) + store, err := config.ProviderKeyStore() if err != nil { t.Fatal(err) } @@ -572,7 +573,7 @@ func TestRunProvidersRemoveFailsWhenStoredKeyCleanupFails(t *testing.T) { } // A directory at the lock-file path is a hermetic, cross-platform // failure: Delete cannot acquire its write lock. - lockPath := filepath.Join(dir, "credentials.json.lock") + lockPath := filepath.Join(userConfigRoot, "zero", "credentials.json.lock") if err := os.Remove(lockPath); err != nil { t.Fatal(err) } @@ -617,13 +618,14 @@ func TestRunProvidersRemoveFailsWhenStoredKeyCleanupFails(t *testing.T) { func TestRunProvidersRemoveKeepsSharedCredentialForCaseVariantSurvivor(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCLIUserConfigRoot(t) dir := t.TempDir() configPath := filepath.Join(dir, "config.json") seed := []byte(`{"activeProvider":"work","providers":[{"name":"work","apiKeyStored":true},{"name":"WORK","apiKeyStored":true}]}`) if err := os.WriteFile(configPath, seed, 0o600); err != nil { t.Fatal(err) } - store, err := config.ProviderKeyStoreAt(dir) + store, err := config.ProviderKeyStore() if err != nil { t.Fatal(err) } diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 5ca3ac555..e90097642 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -66,10 +66,9 @@ func SecureProviderProfile(profile ProviderProfile, configPath string) ProviderP return secured } -// PublishProviderCredential captures key into the credential store beside path -// and publishes the matching APIKeyStored marker for exactName as ONE -// operation, so a rejected publication cannot leave the user worse off than -// before the call. +// PublishProviderCredential captures key into the user-scoped credential store +// and publishes the matching APIKeyStored marker for exactName as ONE operation, +// so a rejected publication cannot leave the user worse off than before the call. // // Hand-rolled Set-then-Mark sequences got this wrong in both directions: they // wrote the secret before any validation could reject the config, and their @@ -95,7 +94,7 @@ func PublishProviderCredential(path string, exactName string, key string) error if err := PreflightUserConfig(path); err != nil { return err } - store, err := ProviderKeyStoreAt(filepath.Dir(path)) + store, err := ProviderKeyStore() if err != nil { return err } diff --git a/internal/config/credentials_test.go b/internal/config/credentials_test.go index 0841bf8cd..b7e37ca78 100644 --- a/internal/config/credentials_test.go +++ b/internal/config/credentials_test.go @@ -5,10 +5,25 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" ) +func setCredentialTestUserConfigRoot(t *testing.T) { + t.Helper() + + root := t.TempDir() + switch runtime.GOOS { + case "windows": + t.Setenv("APPDATA", root) + case "darwin": + t.Setenv("XDG_CONFIG_HOME", root) + default: + t.Setenv("XDG_CONFIG_HOME", root) + } +} + type fakeKeyGetter struct { keys map[string]string err error @@ -377,6 +392,7 @@ func TestClearProviderKeyStoredCaseVariantsPreservesDistinctUnicodeIdentity(t *t // call had created the entry. func TestPublishProviderCredentialRestoresPreviousKeyWhenMarkerRejected(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCredentialTestUserConfigRoot(t) dir := t.TempDir() path := filepath.Join(dir, "config.json") // Legacy duplicate rows: the write-time validator rejects this config, so @@ -385,7 +401,7 @@ func TestPublishProviderCredentialRestoresPreviousKeyWhenMarkerRejected(t *testi if err := os.WriteFile(path, original, 0o600); err != nil { t.Fatal(err) } - store, err := ProviderKeyStoreAt(dir) + store, err := ProviderKeyStore() if err != nil { t.Fatal(err) } @@ -417,6 +433,7 @@ func TestPublishProviderCredentialRestoresPreviousKeyWhenMarkerRejected(t *testi // publication must not leave an orphaned secret behind either. func TestPublishProviderCredentialDeletesEntryItCreatedWhenMarkerRejected(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCredentialTestUserConfigRoot(t) dir := t.TempDir() path := filepath.Join(dir, "config.json") if err := os.WriteFile(path, []byte(`{"providers":[{"name":"work"},{"name":"WORK"}]}`), 0o600); err != nil { @@ -425,7 +442,7 @@ func TestPublishProviderCredentialDeletesEntryItCreatedWhenMarkerRejected(t *tes if err := PublishProviderCredential(path, "work", "sk-new"); err == nil { t.Fatal("publication must be rejected for an ambiguous persisted config") } - store, err := ProviderKeyStoreAt(dir) + store, err := ProviderKeyStore() if err != nil { t.Fatal(err) } @@ -438,6 +455,7 @@ func TestPublishProviderCredentialDeletesEntryItCreatedWhenMarkerRejected(t *tes func TestPublishProviderCredentialStoresAndMarks(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCredentialTestUserConfigRoot(t) dir := t.TempDir() path := filepath.Join(dir, "config.json") if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openrouter","apiKeyEnv":"OPENROUTER_API_KEY"}]}`), 0o600); err != nil { @@ -446,7 +464,7 @@ func TestPublishProviderCredentialStoresAndMarks(t *testing.T) { if err := PublishProviderCredential(path, "openrouter", "sk-new"); err != nil { t.Fatal(err) } - store, err := ProviderKeyStoreAt(dir) + store, err := ProviderKeyStore() if err != nil { t.Fatal(err) } From 9fc1c038646747b5e328a30a7b036ffe122c8540 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 21 Aug 2026 18:14:44 +0200 Subject: [PATCH 15/17] test(provider): resolve credential lock path portably --- internal/cli/provider_onboarding_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/cli/provider_onboarding_test.go b/internal/cli/provider_onboarding_test.go index f68a0e507..bc53c4788 100644 --- a/internal/cli/provider_onboarding_test.go +++ b/internal/cli/provider_onboarding_test.go @@ -558,7 +558,7 @@ func TestRunProvidersRemoveFailsWhenStoredKeyCleanupFails(t *testing.T) { } t.Run(name, func(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "file") - userConfigRoot := setCLIUserConfigRoot(t) + setCLIUserConfigRoot(t) dir := t.TempDir() configPath := filepath.Join(dir, "config.json") if err := os.WriteFile(configPath, []byte(`{"providers":[{"name":"gw","apiKeyStored":true}]}`), 0o600); err != nil { @@ -573,7 +573,11 @@ func TestRunProvidersRemoveFailsWhenStoredKeyCleanupFails(t *testing.T) { } // A directory at the lock-file path is a hermetic, cross-platform // failure: Delete cannot acquire its write lock. - lockPath := filepath.Join(userConfigRoot, "zero", "credentials.json.lock") + userConfigPath, err := config.DefaultUserConfigPath() + if err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(filepath.Dir(userConfigPath), "credentials.json.lock") if err := os.Remove(lockPath); err != nil { t.Fatal(err) } From 2b8faf399136c66444d5b06112044051ca5dab5e Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 22 Aug 2026 13:55:04 +0200 Subject: [PATCH 16/17] fix(config): migrate repaired active provider --- internal/cli/provider_onboarding_test.go | 29 ++++++++++++++++++++++++ internal/config/writer.go | 21 +++++++++++++++++ internal/config/writer_test.go | 25 ++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/internal/cli/provider_onboarding_test.go b/internal/cli/provider_onboarding_test.go index bc53c4788..b17c37f47 100644 --- a/internal/cli/provider_onboarding_test.go +++ b/internal/cli/provider_onboarding_test.go @@ -89,6 +89,35 @@ func TestRunProvidersRepairConfigRecoversLegacyUnnamedProvider(t *testing.T) { } } +func TestRunProvidersRepairConfigMigratesLegacyActiveReference(t *testing.T) { + var stdout, stderr bytes.Buffer + configPath := filepath.Join(t.TempDir(), "zero", "config.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatal(err) + } + seed := []byte(`{"activeProvider":"legacy","providers":[{"name":"","provider_kind":"openai","model":"gpt-4o"},{"name":"other","provider_kind":"openai","model":"gpt-4.1"}]}`) + if err := os.WriteFile(configPath, seed, 0o600); err != nil { + t.Fatal(err) + } + + code := runWithDeps( + []string{"providers", "repair-config", "--name", "work"}, + &stdout, + &stderr, + providerSetupDeps(configPath), + ) + if code != exitSuccess { + t.Fatalf("repair exit = %d, stderr=%q", code, stderr.String()) + } + resolved, err := config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}}) + if err != nil { + t.Fatalf("fresh Resolve after repair: %v", err) + } + if resolved.ActiveProvider != "work" || resolved.Provider.Name != "work" { + t.Fatalf("resolved active provider = %q profile = %q, want work", resolved.ActiveProvider, resolved.Provider.Name) + } +} + func TestProviderRepairCommandsCanResolveIndependentLegacyNameProblems(t *testing.T) { var stdout, stderr bytes.Buffer configPath := filepath.Join(t.TempDir(), "zero", "config.json") diff --git a/internal/config/writer.go b/internal/config/writer.go index 6f24d9184..7bc692e18 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -127,6 +127,20 @@ func RepairUnnamedProvider(path string, replacement string) (FileConfig, error) if unnamed < 0 { return FileConfig{}, fmt.Errorf("no unnamed persisted provider found") } + activeName := strings.TrimSpace(cfg.ActiveProvider) + activeMatchesNamedRow := false + if activeName != "" { + for index := range cfg.Providers { + rowName := strings.TrimSpace(cfg.Providers[index].Name) + if rowName == "" { + continue + } + if rowName == activeName || sameProviderIdentity(rowName, activeName) { + activeMatchesNamedRow = true + break + } + } + } name := strings.TrimSpace(replacement) if name == "" { name = strings.TrimSpace(cfg.ActiveProvider) @@ -135,6 +149,13 @@ func RepairUnnamedProvider(path string, replacement string) (FileConfig, error) name = "openai" } cfg.Providers[unnamed].Name = name + // A nonempty active name that matched no named row was the legacy selector + // for this sole unnamed row. Repair the reference in the same atomic write; + // otherwise an explicit --name can report success but leave Resolve unable to + // find the active provider. + if activeName != "" && !activeMatchesNamedRow { + cfg.ActiveProvider = name + } var before FileConfig if err := json.Unmarshal(data, &before); err != nil { return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go index 218eaa525..e911af45f 100644 --- a/internal/config/writer_test.go +++ b/internal/config/writer_test.go @@ -1345,6 +1345,31 @@ func TestRepairUnnamedProviderPreservesLegacyNameResolution(t *testing.T) { } }) + t.Run("explicit name migrates legacy active reference", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + writeConfigFixture(t, path, FileConfig{ + ActiveProvider: "legacy", + Providers: []ProviderProfile{ + {Name: "", ProviderKind: ProviderKindOpenAI, Model: "gpt-4o"}, + {Name: "other", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"}, + }, + }, 0o600) + cfg, err := RepairUnnamedProvider(path, "work") + if err != nil { + t.Fatal(err) + } + if cfg.ActiveProvider != "work" { + t.Fatalf("active provider = %q, want repaired name work", cfg.ActiveProvider) + } + resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}}) + if err != nil { + t.Fatalf("Resolve after repair: %v", err) + } + if resolved.ActiveProvider != "work" || resolved.Provider.Name != "work" { + t.Fatalf("resolved active provider = %q profile = %q, want work", resolved.ActiveProvider, resolved.Provider.Name) + } + }) + t.Run("openai fallback", func(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Model: "gpt-4o"}}}, 0o600) From 5ad69c086c15b9374810f8f0f9b038df3861c40c Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 23 Aug 2026 19:05:28 +0200 Subject: [PATCH 17/17] fix(provider): resolve identity ownership before mutating persisted rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from the latest review round, most tracing to one root cause: resolved providers from user config, project config, environment discovery, and the live session are flattened into ProviderProfile values and then re-identified from the display Name. A row's Name says nothing about which layer produced it, so "shares a credential identity with a user row" was being treated as "IS that user row" — unsafe as soon as exact case-sibling profiles exist across layers, which the resolver explicitly permits. ## Provider identity ownership (P1 x2) internal/config/provider_ownership.go adds the ownership model the review asked for: LookupProviderName is the ONE shared rule for resolving a spelling against candidates (exact first, unique-normalized fallback, Ambiguous — not first-match — for several), and ResolveProviderRowOwnership/ProviderRowOwnershipAt answer "which exact persisted row, if any, does this resolved row own?" — rejecting a credential-identity match when a DIFFERENT resolved row already carries that persisted row's exact spelling (the case-sibling shadow). providerManagerRow now carries owner config.ProviderRowOwnership, resolved once per row against the whole displayed list when the manager reloads. Delete, edit, and the delete-confirmation key note all consume row.owner instead of re-deriving it from a name lookup — closing the defect where a project WORK row edited or deleted through a user work row it merely shared a credential identity with. savedProviderByName, the model-picker owner-routing branch, the "is this row active" badge, and persistSelectedModel/switchProviderModel's persistence gate are rewritten onto the same rule: compare resolved ROWS, not credential identities, and refuse to write when ownership is ambiguous or shadowed rather than picking a row at random. A necessary follow-up mid-implementation: switchProviderModel's silent path for a genuinely environment-derived provider (no persisted row at all) had to stay silent, matching prior behavior — only the surprising outcomes (shadowed by a sibling, ambiguous) earn the new session-only note. ProviderRowOwnership.Lookup/.Shadowed exist so callers can tell these apart without parsing Reason's text. ## OpenRouter preflight (P2) runAuthOpenRouter now calls preflightAuthLogin before the browser PKCE flow, matching the sibling chatgpt/login commands, instead of only inside saveOpenRouterProviderKey after the flow already minted a live remote credential. The second check stays in place immediately before publication — the config can change while the browser flow is open. ## Bare repair colliding with its own default name (P2) RepairUnnamedProvider stops proposing activeProvider as the unnamed row's name once activeProvider already selects a DIFFERENT named row (evidence the pointer belongs to that row, not a name for this one), and now checks the proposed default for a collision BEFORE mutating rather than building a candidate, rejecting it, and reporting an "ambiguous" state the file never had. The rejection names the owning row and the working --name escape. The function also returns the name it actually chose, so the CLI stops re-deriving it and reporting the wrong one. ## Model-only saved-state sync (P2) syncSavedProviderModel no longer routes through applySavedProviderEdit, a full-edit mirror with no field-presence semantics: it now copies the slice and updates only Model, so a model persistence no longer clears a nonempty Description (and other holders of the old backing array are not affected by a copy that never mutated in place). ## Completion parity (P3) providersSubcommands in command_center.go is the one inventory dispatch, help, and the completion tree now share (via aliasNodes), plus a parity test, so repair-config shipping in the first two while no generated completion script offered it cannot happen again for the next command. Tests: internal/config/provider_ownership_test.go pins the ownership matrix (exact, shadowed sibling in both directions, sole case variant, env-only, ambiguous, distinct s/long-s identities) and the shared lookup rule directly. internal/tui/provider_ownership_test.go exercises delete, edit, and model-picker selection end-to-end with a live user `work` + project `WORK` pair in both active orders, asserting the config bytes, credential store, and in-memory session state together — not just the visible row. CLI and config packages get direct regressions for the OpenRouter preflight ordering and both repair-config collision shapes. Validation: go build ./..., go vet ./... (also on Linux via WSL, go1.26.6), gofmt clean, deadcode clean, go test ./internal/{cli,config, tui}/... green on both Windows and Linux. The internal/cli provider-config test failures are pre-existing on this branch (confirmed identical failure set against the unmodified branch) and unrelated to this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JgWC2FnDp5Jjdvc6cqEfEQ --- internal/cli/auth.go | 12 + internal/cli/auth_test.go | 60 +++- internal/cli/command_center.go | 25 ++ internal/cli/completions.go | 20 +- internal/cli/completions_test.go | 90 ++++++ internal/cli/provider_onboarding.go | 23 +- internal/cli/provider_onboarding_test.go | 99 ++++++ internal/config/provider_ownership.go | 187 +++++++++++ internal/config/provider_ownership_test.go | 151 +++++++++ internal/config/writer.go | 104 +++++-- internal/config/writer_test.go | 23 +- internal/tui/command_center.go | 86 ++++-- internal/tui/model.go | 24 +- internal/tui/picker.go | 12 +- internal/tui/provider_manager.go | 135 +++++--- internal/tui/provider_manager_test.go | 47 ++- internal/tui/provider_ownership_test.go | 343 +++++++++++++++++++++ internal/tui/provider_wizard.go | 11 +- 18 files changed, 1301 insertions(+), 151 deletions(-) create mode 100644 internal/config/provider_ownership.go create mode 100644 internal/config/provider_ownership_test.go create mode 100644 internal/tui/provider_ownership_test.go diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 4b4dbd689..62b9bcc06 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -108,6 +108,18 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps a if len(args) > 0 { return writeExecUsageError(stderr, fmt.Sprintf("zero auth openrouter takes no arguments (got %q)", args[0])) } + // Two checks, one lifecycle. This one refuses config we can already tell is + // unpublishable BEFORE the browser flow mints a live remote credential: + // validation lived only inside saveOpenRouterProviderKey, past the + // irreversible boundary, so a legacy unnamed or duplicate-name config sent + // the user through OpenRouter's authorization and then handed back an + // orphaned key with a nonzero exit. The sibling chatgpt and login flows check + // here too. The second check stays where it is, immediately before + // EnsureCatalogProvider, because the config can change while the browser flow + // is open. + if err := preflightAuthLogin(deps); err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } key, err := deps.openRouterLogin(context.Background(), provideroauth.OpenRouterOptions{ Out: stdout, HTTPClient: &http.Client{Timeout: 30 * time.Second}, diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 9bb3fe20e..0a6fb26fd 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -502,19 +502,63 @@ func TestRunAuthLogoutRejectsConfigPathFailureBeforeCredentialDeletion(t *testin } } -// A legacy duplicate-row config cannot be published, and the rejection must not -// cost the user the OpenRouter key they were already working with: the capture -// is validated first, and a rejected publication restores the previous secret -// rather than deleting the shared entry. +// Local state that already makes publication impossible must be rejected BEFORE +// the browser flow mints a live remote credential. Validation used to live only +// inside saveOpenRouterProviderKey, past that irreversible boundary, so a legacy +// config sent the user through OpenRouter's authorization and then handed back +// an orphaned key with a nonzero exit. +func TestRunAuthOpenRouterRejectsInvalidConfigBeforeAuthorizing(t *testing.T) { + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + setCLIUserConfigRoot(t) + configPath := filepath.Join(t.TempDir(), "config.json") + seed := `{"activeProvider":"openrouter","providers":[{"name":"openrouter","apiKeyStored":true},{"name":"OPENROUTER","apiKeyStored":true}]}` + if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := runWithDeps([]string{"auth", "openrouter"}, &stdout, &stderr, appDeps{ + userConfigPath: func() (string, error) { return configPath, nil }, + openRouterLogin: func(context.Context, provideroauth.OpenRouterOptions) (string, error) { + t.Error("browser authorization started despite config that cannot be published") + return "sk-minted", nil + }, + }) + if code == exitSuccess { + t.Fatalf("exit = %d, want non-zero for a config that cannot be published", code) + } + if !strings.Contains(stderr.String(), "ambiguous persisted provider names") { + t.Fatalf("stderr = %q, want the local rejection", stderr.String()) + } + // No key was minted, so nothing is handed back for manual use either. + if strings.Contains(stdout.String(), "sk-minted") { + t.Fatalf("stdout = %q, want no minted key", stdout.String()) + } + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(after) != seed { + t.Fatalf("rejected login rewrote config:\n%s", after) + } +} + +// The second check is not redundant with the first: the config can change while +// the browser flow is open. A legacy duplicate-row config that appears during +// authorization must still be rejected, and the rejection must not cost the user +// the OpenRouter key they were already working with — the capture is validated +// first, and a rejected publication restores the previous secret rather than +// deleting the shared entry. func TestRunAuthOpenRouterPreservesExistingKeyWhenConfigRejected(t *testing.T) { t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") setCLIUserConfigRoot(t) dir := t.TempDir() configPath := filepath.Join(dir, "config.json") - seed := `{"activeProvider":"openrouter","providers":[{"name":"openrouter","apiKeyStored":true},{"name":"OPENROUTER","apiKeyStored":true}]}` - if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil { + // Valid when the command starts, so the preflight before authorization passes. + if err := os.WriteFile(configPath, []byte(`{"activeProvider":"openrouter","providers":[{"name":"openrouter","apiKeyStored":true}]}`), 0o600); err != nil { t.Fatal(err) } + seed := `{"activeProvider":"openrouter","providers":[{"name":"openrouter","apiKeyStored":true},{"name":"OPENROUTER","apiKeyStored":true}]}` store, err := config.ProviderKeyStore() if err != nil { t.Fatal(err) @@ -527,6 +571,10 @@ func TestRunAuthOpenRouterPreservesExistingKeyWhenConfigRejected(t *testing.T) { code := runWithDeps([]string{"auth", "openrouter"}, &stdout, &stderr, appDeps{ userConfigPath: func() (string, error) { return configPath, nil }, openRouterLogin: func(context.Context, provideroauth.OpenRouterOptions) (string, error) { + // Another process edits config.json while the browser flow is open. + if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil { + t.Fatalf("seed the mid-authorization config: %v", err) + } return "sk-minted", nil }, }) diff --git a/internal/cli/command_center.go b/internal/cli/command_center.go index 8ebab9f98..f850acac8 100644 --- a/internal/cli/command_center.go +++ b/internal/cli/command_center.go @@ -54,6 +54,31 @@ func runConfig(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) return exitSuccess } +// providersSubcommands is the ONE inventory of `zero providers` subcommands. +// Dispatch, the help text, and the shell completion tree previously each kept +// their own list, and `repair-config` shipped in the first two while every +// generated completion script offered the old set — so the recovery command the +// new validation errors name could not be tab-completed into existence. +// +// The completion tree is built from this slice (see completionRoot) and +// TestProvidersSubcommandInventoryMatchesDispatchAndHelp holds the other two +// surfaces to it, so a new subcommand cannot reach users through one door only. +// Each entry is the canonical name first, then its aliases. +var providersSubcommands = [][]string{ + {"current"}, + {"list"}, + {"catalog"}, + {"add"}, + {"check"}, + {"use"}, + {"remove", "rm"}, + {"rename"}, + {"repair-config"}, + {"setup"}, + {"detect"}, + {"models"}, +} + func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { command := "list" if len(args) > 0 && !strings.HasPrefix(args[0], "-") { diff --git a/internal/cli/completions.go b/internal/cli/completions.go index 5f9058946..63fdb9be6 100644 --- a/internal/cli/completions.go +++ b/internal/cli/completions.go @@ -42,12 +42,10 @@ var completionRoot = completionNode{ {names: []string{"setup"}}, {names: []string{"config"}}, {names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}}, - {names: []string{"providers"}, children: []completionNode{ - {names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}}, - {names: []string{"add"}}, {names: []string{"check"}}, {names: []string{"use"}}, - {names: []string{"remove", "rm"}}, {names: []string{"rename"}}, {names: []string{"setup"}}, - {names: []string{"detect"}}, {names: []string{"models"}}, - }}, + // Built from providersSubcommands rather than restated here: the two + // inventories drifted, and `repair-config` was dispatched and documented + // while no generated script could complete it. + {names: []string{"providers"}, children: aliasNodes(providersSubcommands)}, {names: []string{"doctor"}}, {names: []string{"context"}}, {names: []string{"repo-map", "repomap"}}, @@ -103,6 +101,16 @@ var completionRoot = completionNode{ }, } +// aliasNodes builds one leaf per command, keeping each command's aliases on the +// same node so a generated script offers every accepted spelling. +func aliasNodes(commands [][]string) []completionNode { + nodes := make([]completionNode, 0, len(commands)) + for _, names := range commands { + nodes = append(nodes, completionNode{names: append([]string{}, names...)}) + } + return nodes +} + func leafNodes(names ...string) []completionNode { nodes := make([]completionNode, 0, len(names)) for _, name := range names { diff --git a/internal/cli/completions_test.go b/internal/cli/completions_test.go index b7eb059b7..1a55e63bb 100644 --- a/internal/cli/completions_test.go +++ b/internal/cli/completions_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "os" "os/exec" "strings" "testing" @@ -177,3 +178,92 @@ func assertCandidates(t *testing.T, got []string, wants ...string) { } } } + +// Dispatch, help, and completions each carried their own list of provider +// subcommands, and they drifted: `repair-config` was dispatched and documented +// while no generated script could complete it — so the recovery command the new +// validation errors point users at was undiscoverable by tab. +// +// completionRoot now builds the providers node from providersSubcommands. This +// holds the other two surfaces to the same inventory, so the next provider +// command cannot ship through one door only. +func TestProvidersSubcommandInventoryMatchesDispatchAndHelp(t *testing.T) { + var help bytes.Buffer + if err := writeProvidersHelp(&help); err != nil { + t.Fatalf("writeProvidersHelp: %v", err) + } + helpText := help.String() + + dispatch, err := os.ReadFile("command_center.go") + if err != nil { + t.Fatalf("read dispatch source: %v", err) + } + dispatchSource := runProvidersDispatchSource(t, string(dispatch)) + + contexts := completionContexts(completionRoot) + var completionCandidates []string + for _, context := range contexts { + if context.path == "providers" { + completionCandidates = context.candidates + } + } + if completionCandidates == nil { + t.Fatal("completion contexts have no providers path") + } + completed := make(map[string]bool, len(completionCandidates)) + for _, candidate := range completionCandidates { + completed[candidate] = true + } + + for _, names := range providersSubcommands { + canonical := names[0] + // Help documents the canonical spelling; aliases are not separate lines. + if !strings.Contains(helpText, "zero providers "+canonical) { + t.Errorf("providers help does not document %q", canonical) + } + for _, name := range names { + if !completed[name] { + t.Errorf("providers completion context does not offer %q (candidates: %v)", name, completionCandidates) + } + // `list`, `current`, and `catalog` fall through to the shared + // options parser rather than an `if command ==` branch, so they are + // matched by the final guard instead. + if !strings.Contains(dispatchSource, `"`+name+`"`) { + t.Errorf("runProviders does not dispatch %q", name) + } + } + } + + // The reverse direction: a command the completion tree offers but nothing + // dispatches would be just as broken. + known := make(map[string]bool) + for _, names := range providersSubcommands { + for _, name := range names { + known[name] = true + } + } + for _, candidate := range completionCandidates { + if strings.HasPrefix(candidate, "-") { + continue + } + if !known[candidate] { + t.Errorf("providers completion offers %q, which is not in providersSubcommands", candidate) + } + } +} + +// runProvidersDispatchSource returns just the body of runProviders, so a name +// mentioned elsewhere in the file cannot satisfy the dispatch assertion. +func runProvidersDispatchSource(t *testing.T, source string) string { + t.Helper() + start := strings.Index(source, "func runProviders(args []string") + if start < 0 { + t.Fatal("runProviders not found in command_center.go") + } + rest := source[start:] + end := strings.Index(rest, "\nfunc ") + if end < 0 { + return rest + } + return rest[:end] +} diff --git a/internal/cli/provider_onboarding.go b/internal/cli/provider_onboarding.go index c326b6507..054be8437 100644 --- a/internal/cli/provider_onboarding.go +++ b/internal/cli/provider_onboarding.go @@ -399,26 +399,15 @@ func runProvidersRepairConfig(args []string, stdout io.Writer, stderr io.Writer, if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) } - cfg, err := config.RepairUnnamedProvider(configPath, options.name) + // repaired comes from the repair itself. Re-deriving the defaulting rules + // here reported activeProvider as the repaired name whenever that value + // already belonged to a different row and the fallback had actually named + // this one — the command said "Named legacy provider Groq" about a row it + // had named "openai". + _, repaired, err := config.RepairUnnamedProvider(configPath, options.name) if err != nil { return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) } - repaired := "" - for _, provider := range cfg.Providers { - if options.name != "" && provider.Name == strings.TrimSpace(options.name) { - repaired = provider.Name - break - } - } - if repaired == "" { - repaired = strings.TrimSpace(options.name) - if repaired == "" { - repaired = strings.TrimSpace(cfg.ActiveProvider) - } - if repaired == "" { - repaired = "openai" - } - } if options.json { if err := writePrettyJSON(stdout, map[string]any{"repairedProvider": repaired, "configPath": configPath}); err != nil { return exitCrash diff --git a/internal/cli/provider_onboarding_test.go b/internal/cli/provider_onboarding_test.go index b17c37f47..81ce6cdbc 100644 --- a/internal/cli/provider_onboarding_test.go +++ b/internal/cli/provider_onboarding_test.go @@ -741,3 +741,102 @@ func TestRunProvidersUseMatchesCredentialIdentityButNotUnicodeCaseFold(t *testin } }) } + +// The bare repair used activeProvider as the unnamed row's default name even +// when that value already selected a DIFFERENT named row. It then proposed a +// duplicate, rejected its own candidate, left the file unchanged, and reported +// that the file contained duplicate rows — about a file whose second row has no +// name at all — without mentioning the --name escape that works. +// +// activeProvider is now only a default while it selects no named row, so this +// case falls through to the "openai" fallback and succeeds. The command also +// reports the name the row actually got: it used to re-derive the defaulting +// rules and say "Named legacy provider Groq" about a row named "openai". +func TestRunProvidersRepairConfigDoesNotProposeAnOwnedActiveName(t *testing.T) { + var stdout, stderr bytes.Buffer + configPath := filepath.Join(t.TempDir(), "zero", "config.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatal(err) + } + seed := `{"activeProvider":"Groq","providers":[{"name":"","provider_kind":"openai","model":"gpt-4o"},{"name":"Groq","provider_kind":"openai","model":"llama"}]}` + if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + if code := runWithDeps([]string{"providers", "repair-config"}, &stdout, &stderr, providerSetupDeps(configPath)); code != exitSuccess { + t.Fatalf("bare repair exit=%d stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Named legacy provider openai") { + t.Fatalf("repair reported a name the row did not get: %q", stdout.String()) + } + cfg := readFileConfig(t, configPath) + if len(cfg.Providers) != 2 || cfg.Providers[0].Name != "openai" || cfg.Providers[1].Name != "Groq" { + t.Fatalf("repaired rows = %+v", cfg.Providers) + } + // activeProvider already selected the named row; the repair must not move it. + if cfg.ActiveProvider != "Groq" { + t.Fatalf("ActiveProvider = %q, want the untouched Groq pointer", cfg.ActiveProvider) + } + resolved, err := config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}}) + if err != nil { + t.Fatalf("fresh Resolve after repair: %v", err) + } + if resolved.Provider.Name != "Groq" { + t.Fatalf("resolved provider = %q, want Groq", resolved.Provider.Name) + } +} + +// When the fallback name is ALSO owned there is no free default left, so the +// repair must stop before mutating and say which name it wanted, who owns it, +// and the command that works. Reporting a duplicate that exists only in the +// rejected candidate state left the user with no next step. +func TestRunProvidersRepairConfigExplainsCollidingDefaultName(t *testing.T) { + var stdout, stderr bytes.Buffer + configPath := filepath.Join(t.TempDir(), "zero", "config.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatal(err) + } + seed := `{"activeProvider":"openai","providers":[{"name":"","provider_kind":"openai","model":"gpt-4o"},{"name":"openai","provider_kind":"openai","model":"gpt-4.1"}]}` + if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + deps := providerSetupDeps(configPath) + + if code := runWithDeps([]string{"providers", "repair-config"}, &stdout, &stderr, deps); code == exitSuccess { + t.Fatalf("bare repair succeeded with no free default name; stdout=%q", stdout.String()) + } + message := stderr.String() + if !strings.Contains(message, `"openai"`) { + t.Fatalf("failure does not name the proposed default: %q", message) + } + if !strings.Contains(message, "zero providers repair-config --name") { + t.Fatalf("failure does not show the escape command: %q", message) + } + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatalf("refused repair rewrote config:\nbefore=%s\nafter=%s", before, after) + } + + // The guidance must actually work, and the result must resolve. + stdout.Reset() + stderr.Reset() + if code := runWithDeps([]string{"providers", "repair-config", "--name", "legacy"}, &stdout, &stderr, deps); code != exitSuccess { + t.Fatalf("guided repair exit=%d stderr=%q", code, stderr.String()) + } + resolved, err := config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}}) + if err != nil { + t.Fatalf("fresh Resolve after guided repair: %v", err) + } + if resolved.Provider.Name != "openai" { + t.Fatalf("resolved provider = %q, want the untouched active openai row", resolved.Provider.Name) + } + if readFileConfig(t, configPath).Providers[0].Name != "legacy" { + t.Fatalf("guided repair did not name the legacy row: %+v", readFileConfig(t, configPath).Providers) + } +} diff --git a/internal/config/provider_ownership.go b/internal/config/provider_ownership.go new file mode 100644 index 000000000..92ae08aa1 --- /dev/null +++ b/internal/config/provider_ownership.go @@ -0,0 +1,187 @@ +package config + +import ( + "fmt" + "strings" +) + +// Provider identity has three distinct questions, and conflating them is what +// let a mutation aimed at one row land on another: +// +// 1. "Which stored secret is this?" — SameProviderIdentity, the credential +// store's normalization. Two spellings can share one secret. +// 2. "Which row does this mutator target?" — exact trimmed equality. Persisted +// writers address rows exactly, because that is what they rewrite. +// 3. "Which persisted row, if any, does this RESOLVED row own?" — this file. +// +// A resolved provider list is a merge of user config, project config, and +// environment discovery, and the merge is exact: user "work" and project "WORK" +// are two rows, not one. Once such a row is flattened into a ProviderProfile, +// its display Name no longer says which layer produced it, so asking question 1 +// and acting as if it answered question 3 made "shares a credential identity +// with a user row" mean "IS that user row" — and a delete or edit of the project +// row rewrote the user's. + +// ProviderNameLookup is the outcome of resolving a provider spelling against a +// set of candidate row names. +type ProviderNameLookup uint8 + +const ( + // ProviderNameNotFound means no candidate carries the spelling or its + // credential identity. + ProviderNameNotFound ProviderNameLookup = iota + // ProviderNameExact means a candidate carries the spelling byte for byte. + ProviderNameExact + // ProviderNameNormalized means exactly one candidate carries the credential + // identity under a different spelling. + ProviderNameNormalized + // ProviderNameAmbiguous means several candidates carry the identity. It is + // deliberately distinct from NotFound: callers that used first-match picked + // one of them silently. + ProviderNameAmbiguous +) + +// Resolved reports whether the lookup produced a usable name. +func (lookup ProviderNameLookup) Resolved() bool { + return lookup == ProviderNameExact || lookup == ProviderNameNormalized +} + +// LookupProviderName is the ONE rule for resolving a provider spelling when only +// a name is available: an exact match wins outright, a credential-identity match +// is accepted only when exactly ONE candidate carries that identity, and several +// candidates return Ambiguous rather than the first one encountered. +// +// The exact-first half is what keeps case siblings distinct — a caller holding +// "work" must never be handed "WORK" while both exist — and the single-candidate +// half is what still lines a session launched with ZERO_PROVIDER=openai up with +// a sole saved "OpenAI" row. +func LookupProviderName(candidates []string, want string) (string, ProviderNameLookup) { + want = strings.TrimSpace(want) + if want == "" { + return "", ProviderNameNotFound + } + match := "" + matches := 0 + for _, candidate := range candidates { + name := strings.TrimSpace(candidate) + if name == "" { + continue + } + if name == want { + return name, ProviderNameExact + } + if sameProviderIdentity(name, want) { + match = name + matches++ + } + } + switch { + case matches == 1: + return match, ProviderNameNormalized + case matches > 1: + return "", ProviderNameAmbiguous + default: + return "", ProviderNameNotFound + } +} + +// ProviderProfileNames extracts row spellings for the lookup helpers. +func ProviderProfileNames(profiles []ProviderProfile) []string { + names := make([]string, 0, len(profiles)) + for _, profile := range profiles { + names = append(names, strings.TrimSpace(profile.Name)) + } + return names +} + +// ProviderRowOwnership is the answer to question 3 above: may this resolved row +// mutate a user-config row, and which exact row? +type ProviderRowOwnership struct { + // UserBacked is true only when a user-config mutator may run for this row. + // A project- or environment-derived row is session-only and never sets it. + UserBacked bool + // PersistedName is the EXACT user-config row spelling to hand to mutators — + // RemoveProvider, EditProvider, SetProviderModel, key deletion. Empty unless + // UserBacked. + PersistedName string + // Reason explains a non-user-backed answer in the user's terms, so a UI can + // say why an edit or delete is session-only instead of silently doing + // something else. + Reason string + // Lookup is the underlying name-resolution outcome: ProviderNameNotFound for + // the ordinary case of a row with no persisted counterpart at all (e.g. an + // environment-derived provider), or ProviderNameAmbiguous when several + // persisted rows share the identity. A caller that wants to stay quiet for + // the ordinary case and surface Reason only for the surprising ones branches + // on this instead of parsing Reason's text. + Lookup ProviderNameLookup + // Shadowed is true when a credential-identity match was found but rejected + // because a DIFFERENT resolved row already carries that persisted row's + // exact spelling — the case-sibling defect this type exists to prevent. + Shadowed bool +} + +// ResolveProviderRowOwnership decides which persisted user-config row a resolved +// provider row owns. +// +// persisted is the user config's rows. resolvedNames is every row spelling in +// the resolved list the caller is displaying — the siblings matter, and are what +// a plain name lookup cannot see. +// +// The rule: +// +// - An exact persisted row is owned outright. +// - Otherwise, a credential-identity match is a candidate only when exactly +// one persisted row carries that identity. Several is ambiguous, and a +// mutation under ambiguity would pick a row at random. +// - The candidate is REJECTED when another resolved row already carries that +// persisted row's exact spelling. That row is the user row's own entry in +// the list; this one is a project or environment row that merely shares a +// credential identity with it, and it must not write through it. +// +// The last clause is the whole defect: with user "work" and project "WORK" both +// resolved, "WORK" found the sole identity match "work" and edited or deleted it +// — while the in-memory operation targeted exact "WORK", so the row changed on +// disk was not the row changed in the session. +func ResolveProviderRowOwnership(persisted []ProviderProfile, resolvedNames []string, name string) ProviderRowOwnership { + name = strings.TrimSpace(name) + if name == "" { + return ProviderRowOwnership{Reason: "this provider has no name"} + } + persistedName, lookup := LookupProviderName(ProviderProfileNames(persisted), name) + switch lookup { + case ProviderNameExact: + return ProviderRowOwnership{UserBacked: true, PersistedName: persistedName, Lookup: lookup} + case ProviderNameAmbiguous: + return ProviderRowOwnership{Lookup: lookup, Reason: fmt.Sprintf( + "several rows in config.json differ from %q only by case; rename or remove one before changing it", name)} + case ProviderNameNotFound: + return ProviderRowOwnership{Lookup: lookup, Reason: fmt.Sprintf("%q isn't saved in config.json", name)} + } + for _, resolved := range resolvedNames { + resolved = strings.TrimSpace(resolved) + if resolved == name || resolved == "" { + continue + } + if resolved == persistedName { + return ProviderRowOwnership{Lookup: lookup, Shadowed: true, Reason: fmt.Sprintf( + "%q is not the saved provider %q — that row is listed separately, so this entry comes from project config or the environment", + name, persistedName)} + } + } + return ProviderRowOwnership{UserBacked: true, PersistedName: persistedName, Lookup: lookup} +} + +// ProviderRowOwnershipAt is ResolveProviderRowOwnership reading the persisted +// rows from a config path. A path that cannot be read is not user-backed: +// nothing may be mutated through a file this process cannot see. +func ProviderRowOwnershipAt(path string, resolvedNames []string, name string) (ProviderRowOwnership, error) { + if strings.TrimSpace(path) == "" { + return ProviderRowOwnership{Reason: "no user config path"}, nil + } + providers, err := persistedProviders(path) + if err != nil { + return ProviderRowOwnership{}, err + } + return ResolveProviderRowOwnership(providers, resolvedNames, name), nil +} diff --git a/internal/config/provider_ownership_test.go b/internal/config/provider_ownership_test.go new file mode 100644 index 000000000..90e9f7871 --- /dev/null +++ b/internal/config/provider_ownership_test.go @@ -0,0 +1,151 @@ +package config + +import "testing" + +// The ownership matrix. Every row is a shape the resolver can validly produce, +// and the answer decides whether a user-config or credential-store mutator may +// run at all — so a wrong answer here is a write to a row the user never chose. +func TestResolveProviderRowOwnership(t *testing.T) { + for _, testCase := range []struct { + name string + // persisted are the user config's rows. + persisted []string + // resolved is every row spelling the session is displaying. + resolved []string + // row is the one being acted on. + row string + wantBacked bool + wantPersisted string + }{ + { + name: "exact user row", + persisted: []string{"work"}, + resolved: []string{"work"}, + row: "work", + wantBacked: true, + wantPersisted: "work", + }, + { + // The defect. Cross-layer merging is exact, so both rows exist; the + // project row must not write through the user row it merely shares a + // credential identity with. + name: "project row beside its user case sibling", + persisted: []string{"work"}, + resolved: []string{"work", "WORK"}, + row: "WORK", + wantBacked: false, + }, + { + // The same pair from the other side: the user row is still its own. + name: "user row beside its project case sibling", + persisted: []string{"work"}, + resolved: []string{"work", "WORK"}, + row: "work", + wantBacked: true, + wantPersisted: "work", + }, + { + // A sole case variant is the case the normalized fallback exists for: + // a session launched with ZERO_PROVIDER=openai against a saved + // "OpenAI" row, with no sibling to confuse it. + name: "sole case variant", + persisted: []string{"OpenAI"}, + resolved: []string{"openai"}, + row: "openai", + wantBacked: true, + wantPersisted: "OpenAI", + }, + { + name: "env-only row with no persisted counterpart", + persisted: []string{"work"}, + resolved: []string{"work", "groq"}, + row: "groq", + wantBacked: false, + }, + { + // Two persisted rows carry the identity, so no single row can be the + // target. First-match would have picked one. + name: "ambiguous persisted rows", + persisted: []string{"work", "WORK"}, + resolved: []string{"work", "WORK", "Work"}, + row: "Work", + wantBacked: false, + }, + { + // "s" and long-s "ſ" are DIFFERENT credential identities — the store + // keeps separate entries — so neither may claim the other's row. + name: "long-s is a distinct identity", + persisted: []string{"s"}, + resolved: []string{"s", "ſ"}, + row: "ſ", + wantBacked: false, + wantPersisted: "", + }, + { + name: "long-s row of its own", + persisted: []string{"s", "ſ"}, + resolved: []string{"s", "ſ"}, + row: "ſ", + wantBacked: true, + wantPersisted: "ſ", + }, + { + name: "unnamed row", + persisted: []string{"work"}, + resolved: []string{"work"}, + row: "", + wantBacked: false, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + persisted := make([]ProviderProfile, 0, len(testCase.persisted)) + for _, name := range testCase.persisted { + persisted = append(persisted, ProviderProfile{Name: name}) + } + owner := ResolveProviderRowOwnership(persisted, testCase.resolved, testCase.row) + if owner.UserBacked != testCase.wantBacked { + t.Fatalf("UserBacked = %t, want %t (reason %q)", owner.UserBacked, testCase.wantBacked, owner.Reason) + } + if owner.PersistedName != testCase.wantPersisted { + t.Fatalf("PersistedName = %q, want %q", owner.PersistedName, testCase.wantPersisted) + } + if !owner.UserBacked && owner.Reason == "" { + t.Fatal("a refusal must carry a reason the UI can show") + } + if owner.UserBacked && owner.Reason != "" { + t.Fatalf("a backed row must carry no refusal reason, got %q", owner.Reason) + } + }) + } +} + +// The shared lookup rule. Ambiguous is a distinct outcome from NotFound because +// the callers this replaces returned the first match instead. +func TestLookupProviderName(t *testing.T) { + for _, testCase := range []struct { + name string + candidates []string + want string + wantName string + wantResult ProviderNameLookup + }{ + {name: "exact wins over a sibling", candidates: []string{"WORK", "work"}, want: "work", wantName: "work", wantResult: ProviderNameExact}, + {name: "exact wins in either order", candidates: []string{"work", "WORK"}, want: "WORK", wantName: "WORK", wantResult: ProviderNameExact}, + {name: "sole identity match", candidates: []string{"OpenAI"}, want: "openai", wantName: "OpenAI", wantResult: ProviderNameNormalized}, + {name: "several identity matches", candidates: []string{"work", "WORK"}, want: "Work", wantResult: ProviderNameAmbiguous}, + {name: "no match", candidates: []string{"work"}, want: "groq", wantResult: ProviderNameNotFound}, + {name: "long-s is not s", candidates: []string{"s"}, want: "ſ", wantResult: ProviderNameNotFound}, + {name: "blank", candidates: []string{"work"}, want: " ", wantResult: ProviderNameNotFound}, + } { + t.Run(testCase.name, func(t *testing.T) { + name, result := LookupProviderName(testCase.candidates, testCase.want) + if result != testCase.wantResult || name != testCase.wantName { + t.Fatalf("LookupProviderName(%q, %q) = %q/%v, want %q/%v", + testCase.candidates, testCase.want, name, result, testCase.wantName, testCase.wantResult) + } + if result.Resolved() != (name != "") { + t.Fatalf("Resolved() = %t for name %q", result.Resolved(), name) + } + }) + } +} diff --git a/internal/config/writer.go b/internal/config/writer.go index 7bc692e18..c9f930c8c 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -101,18 +101,23 @@ func writeProviderNameRepair(path string, before FileConfig, after FileConfig) e // activeProvider, falling back to "openai"; preserve that choice unless the // user supplies a replacement. Multiple unnamed rows are left untouched because // selecting one would silently merge or discard profiles. -func RepairUnnamedProvider(path string, replacement string) (FileConfig, error) { +// +// The chosen name is returned rather than left for the caller to re-derive: the +// defaulting rules below are the only thing that knows which name the row got, +// and the CLI re-deriving them reported activeProvider as the repaired name +// while the row had actually been named by the fallback. +func RepairUnnamedProvider(path string, replacement string) (FileConfig, string, error) { path = strings.TrimSpace(path) if path == "" { - return FileConfig{}, fmt.Errorf("config path is required") + return FileConfig{}, "", fmt.Errorf("config path is required") } data, err := os.ReadFile(path) if err != nil { - return FileConfig{}, fmt.Errorf("read config %s: %w", path, err) + return FileConfig{}, "", fmt.Errorf("read config %s: %w", path, err) } var cfg FileConfig if err := json.Unmarshal(data, &cfg); err != nil { - return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) + return FileConfig{}, "", fmt.Errorf("invalid config JSON %s: %w", path, err) } unnamed := -1 for index := range cfg.Providers { @@ -120,12 +125,12 @@ func RepairUnnamedProvider(path string, replacement string) (FileConfig, error) continue } if unnamed >= 0 { - return FileConfig{}, fmt.Errorf("multiple unnamed persisted providers require manual repair in config.json") + return FileConfig{}, "", fmt.Errorf("multiple unnamed persisted providers require manual repair in config.json") } unnamed = index } if unnamed < 0 { - return FileConfig{}, fmt.Errorf("no unnamed persisted provider found") + return FileConfig{}, "", fmt.Errorf("no unnamed persisted provider found") } activeName := strings.TrimSpace(cfg.ActiveProvider) activeMatchesNamedRow := false @@ -142,11 +147,32 @@ func RepairUnnamedProvider(path string, replacement string) (FileConfig, error) } } name := strings.TrimSpace(replacement) - if name == "" { - name = strings.TrimSpace(cfg.ActiveProvider) + explicit := name != "" + if !explicit { + // activeProvider is a safe default for the unnamed row ONLY while it + // selects no named row. Once activeMatchesNamedRow is true, that value is + // evidence the active pointer belongs to the OTHER row — reusing it as + // this row's name proposes a duplicate, and the validation below then + // rejects a state the file never had, reporting "duplicate rows" + // about a file whose second row has no name at all. + if !activeMatchesNamedRow { + name = activeName + } + if name == "" { + name = "openai" + } } - if name == "" { - name = "openai" + if conflict, collides := conflictingProviderRowName(cfg, unnamed, name); collides { + if explicit { + return FileConfig{}, "", fmt.Errorf( + "cannot name the unnamed provider %q: persisted provider %q already uses that identity; choose a different --name", + name, conflict) + } + // Say what the proposed name was, that it is already taken, and the exact + // command that gets out of it. The bare form has no other escape. + return FileConfig{}, "", fmt.Errorf( + "the unnamed provider would default to %q, which persisted provider %q already uses; rerun with `zero providers repair-config --name `", + name, conflict) } cfg.Providers[unnamed].Name = name // A nonempty active name that matched no named row was the legacy selector @@ -158,12 +184,37 @@ func RepairUnnamedProvider(path string, replacement string) (FileConfig, error) } var before FileConfig if err := json.Unmarshal(data, &before); err != nil { - return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err) + return FileConfig{}, "", fmt.Errorf("invalid config JSON %s: %w", path, err) } if err := writeProviderNameRepair(path, before, cfg); err != nil { - return FileConfig{}, err + return FileConfig{}, "", err } - return cfg, nil + return cfg, name, nil +} + +// conflictingProviderRowName reports the persisted row, other than the one being +// repaired, that already owns the proposed name. Identity is the credential +// store's rule, matching ValidatePersistedProviderNames, so the check refuses +// exactly what the write would refuse — before the write, and with a message +// that describes the file rather than the rejected candidate state. +func conflictingProviderRowName(cfg FileConfig, repairing int, name string) (string, bool) { + name = strings.TrimSpace(name) + if name == "" { + return "", false + } + for index := range cfg.Providers { + if index == repairing { + continue + } + rowName := strings.TrimSpace(cfg.Providers[index].Name) + if rowName == "" { + continue + } + if rowName == name || sameProviderIdentity(rowName, name) { + return rowName, true + } + } + return "", false } // sameProviderIdentity reports whether two persisted spellings name the same @@ -252,27 +303,24 @@ func ResolvePersistedProviderName(path string, input string) (string, error) { return resolvePersistedProviderName(providers, input) } +// resolvePersistedProviderName is LookupProviderName with errors — the shared +// exact-first / unique-normalized / ambiguous rule, not a second copy of it. func resolvePersistedProviderName(providers []ProviderProfile, input string) (string, error) { input = strings.TrimSpace(input) if input == "" { return "", fmt.Errorf("provider name is required") } - match := "" - matches := 0 - for _, provider := range providers { - name := strings.TrimSpace(provider.Name) - if name == input { - return name, nil - } - if sameProviderIdentity(name, input) { - match = name - matches++ + name, lookup := LookupProviderName(ProviderProfileNames(providers), input) + switch lookup { + case ProviderNameExact, ProviderNameNormalized: + return name, nil + case ProviderNameAmbiguous: + matches := 0 + for _, provider := range providers { + if sameProviderIdentity(strings.TrimSpace(provider.Name), input) { + matches++ + } } - } - switch { - case matches == 1: - return match, nil - case matches > 1: return "", fmt.Errorf("ambiguous provider %q: %d rows in config.json differ only by case; rename or remove one row", input, matches) default: return "", fmt.Errorf("provider %q not found", input) diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go index e911af45f..03663950b 100644 --- a/internal/config/writer_test.go +++ b/internal/config/writer_test.go @@ -1216,9 +1216,16 @@ func TestRemoveProviderPublishesRepairWhileUnnamedProblemRemains(t *testing.T) { func TestRepairUnnamedProviderRejectsRepairThatIntroducesDuplicate(t *testing.T) { path := filepath.Join(t.TempDir(), "zero.json") before := writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: ""}, {Name: "work"}}}, 0o600) - _, err := RepairUnnamedProvider(path, "WORK") - if err == nil || !strings.Contains(err.Error(), "ambiguous persisted provider names") { - t.Fatalf("error = %v, want newly introduced duplicate rejection", err) + _, _, err := RepairUnnamedProvider(path, "WORK") + // The collision is now caught BEFORE the candidate config is built, so the + // message names the row that owns the identity instead of reporting an + // "ambiguous persisted provider names" state the file never had. The + // rejection and the untouched file are unchanged. + if err == nil || !strings.Contains(err.Error(), `persisted provider "work" already uses that identity`) { + t.Fatalf("error = %v, want a collision rejection naming the owning row", err) + } + if !strings.Contains(err.Error(), "--name") { + t.Fatalf("error = %v, want the escape flag named", err) } after, readErr := os.ReadFile(path) if readErr != nil || !bytes.Equal(after, before) { @@ -1333,7 +1340,7 @@ func TestRepairUnnamedProviderPreservesLegacyNameResolution(t *testing.T) { Providers: []ProviderProfile{{Name: " ", Model: "legacy-model"}}, MaxTurns: 17, }, 0o600) - cfg, err := RepairUnnamedProvider(path, "") + cfg, _, err := RepairUnnamedProvider(path, "") if err != nil { t.Fatal(err) } @@ -1354,7 +1361,7 @@ func TestRepairUnnamedProviderPreservesLegacyNameResolution(t *testing.T) { {Name: "other", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"}, }, }, 0o600) - cfg, err := RepairUnnamedProvider(path, "work") + cfg, _, err := RepairUnnamedProvider(path, "work") if err != nil { t.Fatal(err) } @@ -1373,7 +1380,7 @@ func TestRepairUnnamedProviderPreservesLegacyNameResolution(t *testing.T) { t.Run("openai fallback", func(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Model: "gpt-4o"}}}, 0o600) - cfg, err := RepairUnnamedProvider(path, "") + cfg, _, err := RepairUnnamedProvider(path, "") if err != nil { t.Fatal(err) } @@ -1391,7 +1398,7 @@ func TestRepairUnnamedProviderRejectsAmbiguousRepairWithoutWriting(t *testing.T) t.Run(name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") before := writeConfigFixture(t, path, cfg, 0o600) - if _, err := RepairUnnamedProvider(path, ""); err == nil { + if _, _, err := RepairUnnamedProvider(path, ""); err == nil { t.Fatal("ambiguous repair succeeded") } after, err := os.ReadFile(path) @@ -1408,7 +1415,7 @@ func TestRepairUnnamedProviderRejectsAmbiguousRepairWithoutWriting(t *testing.T) func TestRepairUnnamedProviderAllowsExplicitUniqueName(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: ""}, {Name: "OPENAI"}}}, 0o600) - cfg, err := RepairUnnamedProvider(path, "legacy") + cfg, _, err := RepairUnnamedProvider(path, "legacy") if err != nil { t.Fatal(err) } diff --git a/internal/tui/command_center.go b/internal/tui/command_center.go index da5efef74..f38390817 100644 --- a/internal/tui/command_center.go +++ b/internal/tui/command_center.go @@ -596,12 +596,17 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string, // Env-derived providers have no row to update, so they are skipped // silently; a failure to write a row that DOES exist is surfaced rather // than swallowed, since the session and config.json then disagree. - persisted, err := config.ProviderPersisted(m.userConfigPath, target.Name) + // Ownership, not a credential-identity probe: "config.json carries this + // identity" was true for a project row whose identity a DIFFERENT user + // row owns, and the switch then pointed activeProvider at that user row + // and wrote this row's model onto it — a profile with another endpoint + // that the user never selected. + owner, err := config.ProviderRowOwnershipAt(m.userConfigPath, config.ProviderProfileNames(m.savedProviders), target.Name) switch { case err != nil: persistNote = "\nNote: the switch applies to this session, but config.json could not be read: " + redaction.RedactString(err.Error(), redaction.Options{}) - case persisted: - if cfg, err := config.SetActiveProvider(m.userConfigPath, target.Name); err != nil { + case owner.UserBacked: + if cfg, err := config.SetActiveProvider(m.userConfigPath, owner.PersistedName); err != nil { persistNote = "\nNote: the switch applies to this session, but config.json was not updated: " + redaction.RedactString(err.Error(), redaction.Options{}) } else if _, err := config.SetProviderModel(m.userConfigPath, cfg.ActiveProvider, target.Model); err != nil { persistNote = "\nNote: the active provider was saved, but its model was not: " + redaction.RedactString(err.Error(), redaction.Options{}) @@ -610,6 +615,16 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string, // or those surfaces keep showing the previous model until restart. m.savedProviders = syncSavedProviderModel(m.savedProviders, cfg.ActiveProvider, target.Model) } + case owner.Lookup == config.ProviderNameNotFound: + // The ordinary case: an environment-derived provider has no + // config.json row to update at all. Stay silent, same as before — + // only the surprising outcomes below (shadowed, ambiguous) are worth + // a note. + default: + // Shadowed by a listed sibling, or ambiguous: say so rather than + // silently writing through a row that only shares the credential + // identity, or picking one of several at random. + persistNote = "\nNote: the switch applies to this session only — " + owner.Reason + "." } } // Warm discovery for the provider we just switched to, same as Init() does @@ -703,19 +718,42 @@ func oauthLoginName(profile config.ProviderProfile) (string, bool) { return strings.TrimPrefix(key, oauth.KeyPrefixProvider), true } -// savedProviderByName resolves a provider spelling to its saved profile using -// the credential store's own normalization rather than strings.EqualFold. The -// two disagree: EqualFold folds "s" and Unicode long-s "ſ" together, while the -// store keeps separate entries for them, so EqualFold could hand back a -// different provider's profile and reach its secret. +// activeProviderRowName is the saved-row spelling this session actually runs on. +// It is sessionRowName's answer — exact first, sole identity match otherwise — +// so every "is this the provider I am on?" comparison uses one value instead of +// each caller re-deciding what "active" means from a credential identity. +func (m model) activeProviderRowName() string { + return sessionRowName(m.providerName, m.savedProviders) +} + +// savedProviderByName resolves a provider spelling to its saved profile through +// the ONE shared rule (config.LookupProviderName): an exact spelling wins +// outright, a credential-identity match is accepted only when exactly one saved +// row carries that identity, and several rows are AMBIGUOUS rather than +// first-match. +// +// First-match was the defect: with saved "Target" and "target" resolved side by +// side, a lookup for either spelling returned whichever row came first, so a +// model chosen under one endpoint could be applied to the other. The rule also +// stays off strings.EqualFold on purpose — EqualFold folds "s" and Unicode +// long-s "ſ" together while the credential store keeps them separate, so folding +// here could hand back a different provider's profile and reach its secret. func (m model) savedProviderByName(name string) (config.ProviderProfile, bool) { - normalized := credstore.NormalizeProvider(name) - for _, profile := range m.savedProviders { - if credstore.NormalizeProvider(profile.Name) == normalized { - return profile, true + resolved, lookup := config.LookupProviderName(config.ProviderProfileNames(m.savedProviders), name) + if lookup.Resolved() { + for _, profile := range m.savedProviders { + if strings.TrimSpace(profile.Name) == resolved { + return profile, true + } } } - if credstore.NormalizeProvider(m.providerProfile.Name) == normalized { + if lookup == config.ProviderNameAmbiguous { + // Say nothing rather than guess: the caller falls back to the active + // provider, which is a visible outcome, instead of silently writing + // through one of several rows. + return config.ProviderProfile{}, false + } + if _, live := config.LookupProviderName([]string{m.providerProfile.Name}, name); live.Resolved() { return m.providerProfile, true } return config.ProviderProfile{}, false @@ -738,21 +776,23 @@ func (m model) persistSelectedModel(profile config.ProviderProfile) (bool, strin if model == "" { return false, "", nil } - persisted, err := config.ProviderPersisted(path, name) + // Provenance, not a credential-identity probe. "config.json carries this + // identity" was true for a project row whose identity a DIFFERENT user row + // owns, and the model was then persisted onto that user row — a profile the + // user never selected, with its own endpoint. Ownership consults the siblings + // the session resolved, which is what tells those two cases apart, and + // PersistedName is the exact spelling SetProviderModel needs (a case + // difference would otherwise make the write a silent no-op). + owner, err := config.ProviderRowOwnershipAt(path, config.ProviderProfileNames(m.savedProviders), name) if err != nil { return false, "", err } - if !persisted { - // Env-derived providers have no config.json row to update. + if !owner.UserBacked { + // Project- and environment-derived rows have no config.json row of their + // own; the model change stays in this session. return false, "", nil } - // ProviderPersisted matches credential identity; SetProviderModel matches - // the row exactly. Resolve the session's spelling to the row's own before - // writing, or a case difference makes this a silent no-op. - exactName, err := config.ResolvePersistedProviderName(path, name) - if err != nil { - return false, "", err - } + exactName := owner.PersistedName if _, err := config.SetProviderModel(path, exactName, model); err != nil { return false, "", err } diff --git a/internal/tui/model.go b/internal/tui/model.go index 9eb716dc6..9e56ecde5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -21,7 +21,6 @@ import ( "github.com/Gitlawb/zero/internal/agent" "github.com/Gitlawb/zero/internal/config" - "github.com/Gitlawb/zero/internal/credstore" "github.com/Gitlawb/zero/internal/doctor" "github.com/Gitlawb/zero/internal/errhint" "github.com/Gitlawb/zero/internal/lsp" @@ -4414,14 +4413,25 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) { previousProvider, previousModel := m.providerName, m.modelName text := "" owner := strings.TrimSpace(item.OwnerProvider) - _, ownerIsSavedProvider := m.savedProviderByName(owner) - if owner != "" && credstore.NormalizeProvider(owner) != credstore.NormalizeProvider(m.providerName) && ownerIsSavedProvider { + ownerProfile, ownerIsSavedProvider := m.savedProviderByName(owner) + // Compare the resolved owner ROW to the resolved active ROW, not the two + // credential identities. Identity comparison made an item rendered under + // project "target" equal to active user "Target", so the branch below was + // skipped and the model was applied to — and persisted on — the OTHER + // endpoint's profile with nothing shown to say so. savedProviderByName + // now also refuses an ambiguous spelling rather than returning the first + // row, so an unresolvable owner lands on the active provider instead of a + // coin flip. + sameRow := ownerIsSavedProvider && + strings.TrimSpace(ownerProfile.Name) == strings.TrimSpace(m.activeProviderRowName()) + if owner != "" && ownerIsSavedProvider && !sameRow { // A model from another saved provider: switch provider + model together. - m, text, _, cmd = m.switchProviderModel(owner, item.Value) + m, text, _, cmd = m.switchProviderModel(ownerProfile.Name, item.Value) } else { - // OwnerProvider is blank, matches the active provider, or (registry-fallback - // / stale-history rows) doesn't resolve to any saved provider: apply against - // the active provider instead of attempting an unresolvable provider switch. + // OwnerProvider is blank, resolves to the row this session already runs + // on, or (registry-fallback / stale-history / ambiguous rows) resolves + // to no single saved provider: apply against the active provider + // instead of attempting an unresolvable or self-directed switch. m, text = m.handleModelCommand(item.Value) } if m.providerName != previousProvider || m.modelName != previousModel { diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 64554b2c9..a84ab8e3b 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -196,7 +196,11 @@ func (m model) newModelPicker() *commandPicker { return nil } activeModel := strings.TrimSpace(m.modelName) - activeProvider := strings.TrimSpace(m.providerName) + // The saved ROW this session runs on, not the raw live spelling: a + // credential-identity comparison marked BOTH of a pair of case-sibling rows + // active, so the picker showed two "active" endpoints and gave no way to tell + // which one a selection would land on. + activeProvider := strings.TrimSpace(m.activeProviderRowName()) recent := []pickerItem{} for _, pair := range m.recentModelPairsForPicker() { recent = append(recent, m.modelPickerRecentItem(registry, pair.Provider, pair.Model)) @@ -282,7 +286,11 @@ func (m model) modelPickerProviders() []config.ProviderProfile { // active provider prefers its live-discovered models when available. func (m model) savedProviderModelPickerItems(profile config.ProviderProfile, activeProvider, activeModel string) []pickerItem { providerName := strings.TrimSpace(profile.Name) - isActive := providerName != "" && config.SameProviderIdentity(providerName, activeProvider) + // Exact row equality. activeProvider is already the resolved active ROW (see + // newModelPicker), and SameProviderIdentity here marked user "Target" and + // project "target" active at the same time — two rows with different + // endpoints, one badge each, and no way to tell them apart. + isActive := providerName != "" && providerName == strings.TrimSpace(activeProvider) descriptor, hasDescriptor := m.descriptorForProfile(profile) group := modelPickerProviderGroup(profile, descriptor, hasDescriptor) diff --git a/internal/tui/provider_manager.go b/internal/tui/provider_manager.go index 3e5124b5d..f63d156e2 100644 --- a/internal/tui/provider_manager.go +++ b/internal/tui/provider_manager.go @@ -25,10 +25,19 @@ const providerManagerMaxVisible = 10 // providerManagerRow is one saved provider in the list. cred is resolved // asynchronously (keychain reads shell out to `security` on macOS and must // never block the render loop); empty means "still checking". +// +// owner is the row's PROVENANCE, resolved once when the list is built. A row +// carries a resolved profile whose Name says nothing about which layer produced +// it, and the mutation paths used to reconstruct ownership from that string — +// so a project row that merely shared a credential identity with a user row +// edited and deleted through it. Only owner.UserBacked may reach a user-config +// or credential-store mutator, and owner.PersistedName is the exact row those +// mutators address. type providerManagerRow struct { profile config.ProviderProfile local bool cred string + owner config.ProviderRowOwnership } type providerEditField int @@ -96,9 +105,20 @@ func (m model) reloadProviderManagerRows() (model, tea.Cmd) { for _, row := range m.providerWizard.manageRows { previous[row.profile.Name] = row.cred } + // Provenance is resolved ONCE per row, here, against the whole resolved list + // — the siblings are what distinguish "this row is the user's row under a + // different spelling" from "the user's row is listed separately and this one + // is a project/env row that only shares its credential identity". + resolvedNames := config.ProviderProfileNames(m.savedProviders) rows := make([]providerManagerRow, 0, len(m.savedProviders)) for _, profile := range m.savedProviders { row := providerManagerRow{profile: profile, cred: previous[profile.Name]} + owner, err := config.ProviderRowOwnershipAt(m.userConfigPath, resolvedNames, profile.Name) + if err != nil { + // An unreadable config is not a licence to write to it. + owner = config.ProviderRowOwnership{Reason: "config.json could not be read: " + err.Error()} + } + row.owner = owner if descriptor, ok := m.descriptorForProfile(profile); ok { row.local = descriptor.Local } @@ -280,17 +300,25 @@ func (m model) handleProviderManageListKey(msg tea.KeyMsg) (model, tea.Cmd) { return m, nil case strings.EqualFold(keyText(msg), "e"): if row, ok := wizard.currentManagerRow(); ok { - wizard.beginProviderEdit(row.profile) + // An edit writes to config.json and can replace a stored key, so a + // row with no user-config row of its own has nothing to edit. Saying + // so beats applying this row's draft to whichever user row happens to + // share its credential identity. + if !row.owner.UserBacked { + wizard.manageStatus = "Can't edit " + row.profile.Name + ": " + row.owner.Reason + "." + return m, nil + } + wizard.beginProviderEdit(row.profile, row.owner) } return m, nil case strings.EqualFold(keyText(msg), "d"): if row, ok := wizard.currentManagerRow(); ok { wizard.manageDeleting = true wizard.manageStatus = "" - // Resolve the retention outcome now, from the same predicate the + // Resolve the retention outcome now, from the same ownership the // delete uses, so the confirmation cannot promise a key removal the // delete will not perform. - wizard.manageDeleteKeyNote = providerDeleteKeyNote(m.userConfigPath, row.profile.Name) + wizard.manageDeleteKeyNote = providerDeleteKeyNote(m.userConfigPath, row.owner) } return m, nil } @@ -355,22 +383,15 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { return m, nil } - persisted, err := config.ProviderPersisted(m.userConfigPath, name) - if err != nil { - wizard.manageStatus = "Delete failed: " + err.Error() - return m, nil - } var notes []string var activeAfter string var cleanup tea.Cmd - if persisted { - // The manager row carries a RESOLVED name, which may not be the persisted - // row's own spelling; RemoveProvider targets rows exactly. Bridge first. - exactName, err := config.ResolvePersistedProviderName(m.userConfigPath, name) - if err != nil { - wizard.manageStatus = "Delete failed: " + err.Error() - return m, nil - } + // The row's provenance decides this, not a fresh name lookup. Asking + // "does config.json hold this credential identity?" answered yes for a + // project row whose identity a DIFFERENT user row owns, and the delete then + // removed that user row while the in-memory removal took the project one. + if row.owner.UserBacked { + exactName := row.owner.PersistedName cfg, err := config.RemoveProvider(m.userConfigPath, exactName) if err != nil { wizard.manageStatus = "Delete failed: " + err.Error() @@ -385,11 +406,16 @@ func (m model) deleteManagerSelection() (model, tea.Cmd) { } cleanup = providerManagerCleanupCmd(m.userConfigPath, row.profile, deleteStoredKey) } else { - // Env-derived providers have no persisted profile or credential to - // delete. Keep this path session-only. - notes = []string{ - "Removed " + name + " from this session.", - "It wasn't saved in config.json (likely set via an environment variable) — unset it to stop Zero from detecting it automatically.", + // Project- and environment-derived rows have no user-config row and no + // credential of their own to delete. Removing them from the session is + // the whole operation; nothing on disk is touched. The reason names the + // row that DOES own the identity when one exists, so a user who expected + // a saved provider to disappear can see why it did not. + notes = []string{"Removed " + name + " from this session."} + if reason := strings.TrimSpace(row.owner.Reason); reason != "" { + notes = append(notes, reason+" — nothing in config.json changed.") + } else { + notes = append(notes, "It wasn't saved in config.json (likely set via an environment variable) — unset it to stop Zero from detecting it automatically.") } } @@ -431,16 +457,16 @@ func removeSavedProvider(saved []config.ProviderProfile, name string) []config.P } // providerDeleteKeyNote is the delete confirmation's sentence about the stored -// key, computed from the same helpers the delete itself uses so the prompt can -// never promise an outcome the delete will not produce. It returns "" — no -// claim at all — for a row with nothing to say: an env-derived provider with no -// persisted row, no user config path, or a config whose ambiguity will make the -// delete fail before it touches anything. -func providerDeleteKeyNote(configPath string, name string) string { - if strings.TrimSpace(configPath) == "" { +// key, computed from the same OWNERSHIP the delete itself uses so the prompt can +// never promise an outcome the delete will not produce. It returns "" — no claim +// at all — for a row with nothing to say: a project/env row with no user-config +// row of its own, no user config path, or a config whose ambiguity keeps the +// delete off disk entirely. +func providerDeleteKeyNote(configPath string, owner config.ProviderRowOwnership) string { + if strings.TrimSpace(configPath) == "" || !owner.UserBacked { return "" } - retained, err := config.ProviderKeyRetainedAfterRemoval(configPath, name) + retained, err := config.ProviderKeyRetainedAfterRemoval(configPath, owner.PersistedName) if err != nil { return "" } @@ -553,8 +579,9 @@ func (m model) applyProviderManagerCleanup(msg providerManagerCleanupMsg) (model // --- edit ------------------------------------------------------------------- -func (wizard *providerWizardState) beginProviderEdit(profile config.ProviderProfile) { +func (wizard *providerWizardState) beginProviderEdit(profile config.ProviderProfile, owner config.ProviderRowOwnership) { wizard.editOriginal = profile + wizard.editOwner = owner wizard.editDraft = profile wizard.editDraft.APIKey = "" // key field is enter-to-replace, never prefilled wizard.editCursor = 0 @@ -688,23 +715,18 @@ func (m model) saveManagerEdit() (model, tea.Cmd) { return m, nil } oldName := strings.TrimSpace(wizard.editOriginal.Name) - persisted, err := config.ProviderPersisted(m.userConfigPath, oldName) - if err != nil { - wizard.err = err.Error() - return m, nil - } - if !persisted { - wizard.err = "provider " + oldName + " is not saved in config.json, so there is no saved profile to edit" - return m, nil - } - // The edited row came from the resolved list, whose spelling can differ from - // the persisted row's; EditProvider matches rows exactly. Bridge before both - // the credential capture and the write so they target the same row. - exactName, err := config.ResolvePersistedProviderName(m.userConfigPath, oldName) - if err != nil { - wizard.err = err.Error() + // The row's provenance, captured when the edit began — not a fresh lookup + // from oldName. That lookup answered "does config.json carry this credential + // identity?", which is true for a project row whose identity a DIFFERENT user + // row owns, and the draft (replacement key included) was then applied to that + // user row while the session updated the project one. + if !wizard.editOwner.UserBacked { + wizard.err = "cannot edit " + oldName + ": " + wizard.editOwner.Reason return m, nil } + // EditProvider matches rows exactly, and PersistedName is that exact + // spelling, so the credential capture and the write target the same row. + exactName := wizard.editOwner.PersistedName newName := strings.TrimSpace(wizard.editDraft.Name) if newName == "" { wizard.err = "name cannot be empty" @@ -789,11 +811,32 @@ func providerEditRestartNote(liveName string, editedName string, providers []con // // exactName must be the PERSISTED row's spelling — the one SetProviderModel was // handed, not the session's — because savedProviders carries row spellings. +// +// This is a PARTIAL update and must not route through applySavedProviderEdit. +// config.ProviderEdit is a value struct with no field-presence semantics, so an +// omitted field is indistinguishable from an intentional clear: that mirror +// assigns Description unconditionally, and a model-only edit therefore wiped a +// nonempty description out of savedProviders while config.json kept it. The +// manager, the picker, and any copies sharing the slice then disagreed with disk +// until the next full resolution. +// +// The slice is copied rather than mutated in place for the same reason: other +// holders of the backing array — a picker snapshot taken before the switch — +// must not observe a model change through a slice they were handed earlier. func syncSavedProviderModel(saved []config.ProviderProfile, exactName string, model string) []config.ProviderProfile { if strings.TrimSpace(exactName) == "" || strings.TrimSpace(model) == "" { return saved } - return applySavedProviderEdit(saved, exactName, config.ProviderEdit{Name: exactName, Model: model}) + for index := range saved { + if strings.TrimSpace(saved[index].Name) != strings.TrimSpace(exactName) { + continue + } + updated := make([]config.ProviderProfile, len(saved)) + copy(updated, saved) + updated[index].Model = model + return updated + } + return saved } // applySavedProviderEdit mirrors a persisted config.EditProvider into the diff --git a/internal/tui/provider_manager_test.go b/internal/tui/provider_manager_test.go index 6f87f7877..e52c2165f 100644 --- a/internal/tui/provider_manager_test.go +++ b/internal/tui/provider_manager_test.go @@ -722,7 +722,7 @@ func TestProviderManagerKeepsDistinctUnicodeLiveProviderOnOtherRowMutation(t *te t.Run("edit s", func(t *testing.T) { t.Setenv(config.ActiveProviderEnv, "ſ") m := newModelWithRows(t) - m.providerWizard.beginProviderEdit(m.savedProviders[0]) + m.providerWizard.beginProviderEdit(m.savedProviders[0], managerRowOwnership(t, m, m.savedProviders[0].Name)) m.providerWizard.editDraft.Model = "s-updated" next, _ := m.saveManagerEdit() if next.providerName != "ſ" || next.providerProfile.Name != "ſ" { @@ -785,7 +785,7 @@ func TestProviderManagerSoleRowCaseVariantTracksLiveSession(t *testing.T) { t.Fatalf("manageActiveName = %q, want the sole row's spelling WORK", got) } - m.providerWizard.beginProviderEdit(profile) + m.providerWizard.beginProviderEdit(profile, managerRowOwnership(t, m, profile.Name)) m.providerWizard.editDraft.Name = "OFFICE" next, _ := m.saveManagerEdit() @@ -995,16 +995,28 @@ func TestProviderDeleteKeyNoteMakesNoClaimWithoutAResolvableRow(t *testing.T) { name string configJSON string row string + // resolved is every row spelling the manager is displaying. + resolved []string }{ { name: "env-derived row with no persisted profile", configJSON: `{"providers":[{"name":"other"}]}`, row: "openai", + resolved: []string{"other", "openai"}, }, { name: "ambiguous duplicate rows the delete cannot resolve", configJSON: `{"providers":[{"name":"work","apiKeyStored":true},{"name":"WORK","apiKeyStored":true}]}`, row: "Work", + resolved: []string{"work", "WORK", "Work"}, + }, + { + // The project row's identity belongs to the user row listed beside + // it, so this row owns nothing on disk and promises nothing. + name: "project row whose identity a listed user row owns", + configJSON: `{"providers":[{"name":"work","apiKeyStored":true}]}`, + row: "WORK", + resolved: []string{"work", "WORK"}, }, } for _, testCase := range cases { @@ -1013,13 +1025,17 @@ func TestProviderDeleteKeyNoteMakesNoClaimWithoutAResolvableRow(t *testing.T) { if err := os.WriteFile(path, []byte(testCase.configJSON), 0o600); err != nil { t.Fatal(err) } - if note := providerDeleteKeyNote(path, testCase.row); note != "" { + owner, err := config.ProviderRowOwnershipAt(path, testCase.resolved, testCase.row) + if err != nil { + t.Fatal(err) + } + if note := providerDeleteKeyNote(path, owner); note != "" { t.Fatalf("note = %q, want no claim about the stored key", note) } }) } // No user config path at all: nothing can be promised either. - if note := providerDeleteKeyNote("", "work"); note != "" { + if note := providerDeleteKeyNote("", config.ProviderRowOwnership{UserBacked: true, PersistedName: "work"}); note != "" { t.Fatalf("note = %q, want no claim without a config path", note) } } @@ -1034,8 +1050,29 @@ func TestProviderDeleteKeyNoteResolvesCaseVariantSpelling(t *testing.T) { t.Fatal(err) } // "work" addresses the sole WORK row, whose removal takes the key with it. - note := providerDeleteKeyNote(path, "work") + // No other displayed row carries "WORK", so the bridge is safe here — that + // sibling check is the whole difference from the project-row case above. + owner, err := config.ProviderRowOwnershipAt(path, []string{"work", "other"}, "work") + if err != nil { + t.Fatal(err) + } + if !owner.UserBacked || owner.PersistedName != "WORK" { + t.Fatalf("ownership = %+v, want the sole WORK row", owner) + } + note := providerDeleteKeyNote(path, owner) if !strings.Contains(note, "also removes its stored API key") { t.Fatalf("note = %q, want the key-removal wording for the resolved row", note) } } + +// managerRowOwnership resolves a row's provenance exactly as +// reloadProviderManagerRows does, so a test that drives beginProviderEdit +// directly cannot hand the edit a stronger ownership than the manager would. +func managerRowOwnership(t *testing.T, m model, name string) config.ProviderRowOwnership { + t.Helper() + owner, err := config.ProviderRowOwnershipAt(m.userConfigPath, config.ProviderProfileNames(m.savedProviders), name) + if err != nil { + t.Fatalf("resolve ownership for %q: %v", name, err) + } + return owner +} diff --git a/internal/tui/provider_ownership_test.go b/internal/tui/provider_ownership_test.go new file mode 100644 index 000000000..885a56348 --- /dev/null +++ b/internal/tui/provider_ownership_test.go @@ -0,0 +1,343 @@ +package tui + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/credstore" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// caseSiblingModel builds the shape the resolver validly produces and the +// identity comparisons could not tell apart: user config holds "work", and the +// session ALSO resolved a project-config "WORK" with its own endpoint and model. +// +// activeName puts either row in the active seat, because the defect behaved +// differently depending on which one the session ran on. +// +// builtProfiles records every profile handed to newProvider, so a test can +// assert which endpoint a selection actually built — the outcome neither a +// status line nor a config row can show. +func caseSiblingModel(t *testing.T, activeName string, builtProfiles *[]config.ProviderProfile) model { + t.Helper() + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(home, "oauth-tokens.json")) + t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") + + configPath := filepath.Join(t.TempDir(), "config.json") + userRow := config.ProviderProfile{ + Name: "work", + ProviderKind: config.ProviderKindOpenAICompatible, + BaseURL: "https://user.example.com/v1", + Model: "user-model", + Description: "User row", + APIKeyStored: true, + } + seed := config.FileConfig{ActiveProvider: activeName, Providers: []config.ProviderProfile{userRow}} + data, err := json.MarshalIndent(seed, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, data, 0o600); err != nil { + t.Fatal(err) + } + store, err := config.ProviderKeyStore() + if err != nil { + t.Fatal(err) + } + if err := store.Set("work", "sk-user"); err != nil { + t.Fatal(err) + } + + // The project row exists only in the resolved/session list, exactly as + // cross-layer merging produces it: same credential identity, different + // endpoint and model, and NO row of its own in config.json. + projectRow := config.ProviderProfile{ + Name: "WORK", + ProviderKind: config.ProviderKindOpenAICompatible, + BaseURL: "https://project.example.com/v1", + Model: "project-model", + Description: "Project row", + APIKey: "sk-project", + } + resolved := []config.ProviderProfile{userRow, projectRow} + active := userRow + if activeName == "WORK" { + active = projectRow + } + m := newModel(context.Background(), Options{ + ProviderName: activeName, + ModelName: active.Model, + Provider: &fakeProvider{}, + ProviderProfile: active, + SavedProviders: resolved, + UserConfigPath: configPath, + NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) { + if builtProfiles != nil { + *builtProfiles = append(*builtProfiles, profile) + } + return &fakeProvider{}, nil + }, + }) + m.width = 120 + m.height = 40 + next, _ := m.openProviderManager() + return next +} + +// selectManagerRow moves the manager cursor onto the named row. +func selectManagerRow(t *testing.T, m model, name string) model { + t.Helper() + for index, row := range m.providerWizard.manageRows { + if strings.TrimSpace(row.profile.Name) == name { + m.providerWizard.manageCursor = index + return m + } + } + t.Fatalf("manager has no row %q", name) + return m +} + +// assertUserRowUntouched pins both durable surfaces at once: the config bytes +// and the credential store. +func assertUserRowUntouched(t *testing.T, m model, before []byte) { + t.Helper() + after, err := os.ReadFile(m.userConfigPath) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("user config changed:\nbefore=%s\nafter=%s", before, after) + } + store, err := config.ProviderKeyStore() + if err != nil { + t.Fatal(err) + } + key, ok, err := store.Get("work") + if err != nil { + t.Fatal(err) + } + if !ok || key != "sk-user" { + t.Fatalf("user credential changed: present=%t", ok) + } +} + +// Deleting the project row must remove it from the SESSION and leave the user's +// row and its credential exactly as they were. The delete used to resolve the +// project row's spelling onto the user row and remove that instead, so the row +// gone from disk was not the row gone from the list. +func TestProviderManagerDeleteProjectRowLeavesUserRowIntact(t *testing.T) { + for _, activeName := range []string{"work", "WORK"} { + t.Run("active_"+activeName, func(t *testing.T) { + m := caseSiblingModel(t, activeName, nil) + before, err := os.ReadFile(m.userConfigPath) + if err != nil { + t.Fatal(err) + } + m = selectManagerRow(t, m, "WORK") + m = managerKey(t, m, testKeyText("d")) + // The confirmation must promise nothing about a key it cannot delete. + if note := m.providerWizard.manageDeleteKeyNote; note != "" { + t.Fatalf("delete note = %q, want no claim for a row with no config row", note) + } + next, cmd := m.handleProviderWizardKey(testKeyText("y")) + next = drainProviderManagerCmds(t, next, cmd) + + assertUserRowUntouched(t, next, before) + if len(next.savedProviders) != 1 || next.savedProviders[0].Name != "work" { + t.Fatalf("savedProviders = %+v, want only the user row left in session", next.savedProviders) + } + if next.providerWizard == nil { + t.Fatal("manager closed while a provider remains") + } + if !strings.Contains(next.providerWizard.manageStatus, "nothing in config.json changed") { + t.Fatalf("status = %q, want it to say the config was untouched", next.providerWizard.manageStatus) + } + }) + } +} + +// Editing the project row must not apply this row's draft — a replacement key +// included — to the user row that merely shares its credential identity. +func TestProviderManagerEditProjectRowIsRefused(t *testing.T) { + for _, activeName := range []string{"work", "WORK"} { + t.Run("active_"+activeName, func(t *testing.T) { + m := caseSiblingModel(t, activeName, nil) + before, err := os.ReadFile(m.userConfigPath) + if err != nil { + t.Fatal(err) + } + m = selectManagerRow(t, m, "WORK") + m = managerKey(t, m, testKeyText("e")) + + if m.providerWizard.step == providerWizardStepEditMenu { + t.Fatal("edit opened for a row with no config.json row of its own") + } + if !strings.Contains(m.providerWizard.manageStatus, "Can't edit WORK") { + t.Fatalf("status = %q, want a refusal naming the row", m.providerWizard.manageStatus) + } + assertUserRowUntouched(t, m, before) + + // The user row beside it stays editable — the refusal is about + // provenance, not about the name colliding. + m = selectManagerRow(t, m, "work") + m = managerKey(t, m, testKeyText("e")) + if m.providerWizard.step != providerWizardStepEditMenu { + t.Fatalf("user row must stay editable, step = %v status = %q", m.providerWizard.step, m.providerWizard.manageStatus) + } + }) + } +} + +// assertUserProviderRowUnchanged is assertUserRowUntouched for the paths that +// legitimately write elsewhere in config.json — model selection records recent +// models under preferences — so the assertion is on the provider row and the +// active pointer rather than the whole file. +func assertUserProviderRowUnchanged(t *testing.T, m model, want config.ProviderProfile, wantActive string) { + t.Helper() + cfg := readManagerConfig(t, m.userConfigPath) + if cfg.ActiveProvider != wantActive { + t.Fatalf("activeProvider = %q, want %q", cfg.ActiveProvider, wantActive) + } + found := false + for _, provider := range cfg.Providers { + if provider.Name != want.Name { + continue + } + found = true + if provider.Model != want.Model || provider.BaseURL != want.BaseURL || + provider.Description != want.Description || provider.APIKeyStored != want.APIKeyStored { + t.Fatalf("user row changed:\n got %+v\nwant %+v", provider, want) + } + } + if !found { + t.Fatalf("user row %q is gone from %+v", want.Name, cfg.Providers) + } + store, err := config.ProviderKeyStore() + if err != nil { + t.Fatal(err) + } + key, ok, err := store.Get("work") + if err != nil { + t.Fatal(err) + } + if !ok || key != "sk-user" { + t.Fatalf("user credential changed: present=%t", ok) + } +} + +// Choosing a model listed under the project row must build THAT endpoint and +// must not write the model onto the user row. The picker used to treat the two +// as one provider through credential normalization. +func TestModelPickerSelectionStaysOnTheOwningRow(t *testing.T) { + for _, activeName := range []string{"work", "WORK"} { + t.Run("active_"+activeName, func(t *testing.T) { + var built []config.ProviderProfile + m := caseSiblingModel(t, activeName, &built) + m.providerWizard = nil + userRow := config.ProviderProfile{ + Name: "work", + BaseURL: "https://user.example.com/v1", + Model: "user-model", + Description: "User row", + APIKeyStored: true, + } + + m.picker = &commandPicker{ + kind: pickerModel, + items: []pickerItem{{Label: "project-next", Value: "project-next", OwnerProvider: "WORK"}}, + } + updated, _ := m.choosePicker() + next, ok := updated.(model) + if !ok { + t.Fatalf("choosePicker returned %T", updated) + } + + // The user row's model must not move, and its key must not be touched. + assertUserProviderRowUnchanged(t, next, userRow, activeName) + for _, profile := range built { + if strings.TrimSpace(profile.Name) == "work" { + t.Fatalf("selection under WORK built the user row's endpoint: %+v", profile) + } + } + if activeName == "work" { + // A real switch: the project endpoint must be what got built. + if len(built) == 0 { + t.Fatal("no provider was built for a cross-row selection") + } + last := built[len(built)-1] + if last.BaseURL != "https://project.example.com/v1" { + t.Fatalf("built endpoint = %q, want the project row's", last.BaseURL) + } + } + // Whatever happened, the session must not claim to run on the user row + // under the project row's model. + if next.providerName == "work" && next.modelName == "project-next" { + t.Fatalf("project row's model landed on the user row: provider=%q model=%q", next.providerName, next.modelName) + } + }) + } +} + +// Both rows must not read as active at once: they are different endpoints, and +// the picker is where the user decides between them. +func TestActiveProviderRowNameIsTheExactRow(t *testing.T) { + for _, activeName := range []string{"work", "WORK"} { + t.Run("active_"+activeName, func(t *testing.T) { + m := caseSiblingModel(t, activeName, nil) + other := "WORK" + if activeName == "WORK" { + other = "work" + } + if credstore.NormalizeProvider(activeName) != credstore.NormalizeProvider(other) { + t.Fatalf("fixture no longer exercises a shared credential identity: %q vs %q", activeName, other) + } + if got := m.activeProviderRowName(); got != activeName { + t.Fatalf("activeProviderRowName = %q, want the exact active row %q", got, activeName) + } + }) + } +} + +// syncSavedProviderModel is a PARTIAL update: persisting a model must not clear +// the description config.json keeps, and must not reach other holders of the +// slice through the shared backing array. +func TestSyncSavedProviderModelPreservesTheRestOfTheProfile(t *testing.T) { + saved := []config.ProviderProfile{ + {Name: "work", Model: "old", Description: "User row", BaseURL: "https://user.example.com/v1", APIKeyStored: true}, + {Name: "other", Model: "other-model", Description: "Other"}, + } + snapshot := append([]config.ProviderProfile{}, saved...) + + updated := syncSavedProviderModel(saved, "work", "new") + + if updated[0].Model != "new" { + t.Fatalf("model not updated: %+v", updated[0]) + } + for _, field := range []struct{ name, got, want string }{ + {"Description", updated[0].Description, "User row"}, + {"BaseURL", updated[0].BaseURL, "https://user.example.com/v1"}, + } { + if field.got != field.want { + t.Fatalf("%s = %q, want %q", field.name, field.got, field.want) + } + } + if !updated[0].APIKeyStored { + t.Fatal("stored-key marker cleared by a model-only sync") + } + if updated[1].Name != snapshot[1].Name || updated[1].Model != snapshot[1].Model || + updated[1].Description != snapshot[1].Description { + t.Fatalf("unrelated row changed: %+v", updated[1]) + } + // The slice handed in must be unchanged: a picker snapshot taken before the + // switch must not observe the new model through the same backing array. + if saved[0].Model != "old" { + t.Fatalf("input slice mutated in place: %+v", saved[0]) + } +} diff --git a/internal/tui/provider_wizard.go b/internal/tui/provider_wizard.go index 3d39ee7f3..a19f51230 100644 --- a/internal/tui/provider_wizard.go +++ b/internal/tui/provider_wizard.go @@ -475,9 +475,14 @@ type providerWizardState struct { // Edit state: field-level editor for one saved profile. editOriginal config.ProviderProfile editDraft config.ProviderProfile - editCursor int - editField providerEditField - editBuffer string + // editOwner is the provenance of the row being edited, captured when the + // edit began. saveManagerEdit consumes it instead of re-deriving ownership + // from editOriginal.Name, which is a resolved spelling that says nothing + // about which layer produced the row. + editOwner config.ProviderRowOwnership + editCursor int + editField providerEditField + editBuffer string } func (m model) newProviderWizard() *providerWizardState {