Skip to content

Commit bab8db7

Browse files
ampagentPierrunoYT
andcommitted
Merge refreshed provider identity and catalog fixes into transaction stack
Retain the reviewed resume implementation and port the unnamed stored-credential collision guard into the provider transaction boundary. Amp-Thread-ID: https://ampcode.com/threads/T-01a0dcb1-0a19-7184-849e-38dbf12020d5 Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
2 parents 639ac5b + a536157 commit bab8db7

8 files changed

Lines changed: 150 additions & 16 deletions

File tree

‎internal/cli/provider_onboarding.go‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
5858
if err != nil {
5959
return writeAppError(stderr, err.Error(), exitCrash)
6060
}
61-
resolvedName, persisted, err := resolvePersistedProviderName(configPath, options.name)
61+
_, persisted, err := resolvePersistedProviderName(configPath, options.name)
6262
if err != nil {
6363
return writeAppError(stderr, err.Error(), exitCrash)
6464
}
@@ -74,7 +74,10 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
7474
return exit
7575
}
7676
} else {
77-
options.name = resolvedName
77+
options.name, err = resolveProviderMutationName(configPath, options.name, deps)
78+
if err != nil {
79+
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
80+
}
7881
}
7982
cfg, err := config.SetActiveProvider(configPath, options.name)
8083
if err != nil {
@@ -622,7 +625,9 @@ func resolveProviderMutationName(path, name string, deps appDeps) (string, error
622625
return "", err
623626
}
624627
row, lookup := config.LookupProviderName(names, name)
625-
if lookup == config.ProviderNameExact {
628+
// Concrete rows take precedence over catalog aliases, including requests
629+
// that differ only in case from the concrete row's name.
630+
if lookup.Resolved() {
626631
owner, err := config.ProviderRowOwnershipAt(path, names, row)
627632
if err != nil {
628633
return "", err

‎internal/cli/provider_onboarding_test.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,102 @@ import (
1313
"github.com/Gitlawb/zero/internal/config"
1414
)
1515

16+
func TestProviderCommandsRejectConcreteCatalogAlias(t *testing.T) {
17+
for _, source := range []string{"project", "environment"} {
18+
for _, command := range []string{"use", "remove", "rename"} {
19+
for _, address := range []string{"openai", "OPENAI", "work"} {
20+
for _, jsonOutput := range []bool{false, true} {
21+
t.Run(fmt.Sprintf("%s/%s/%s/json=%t", source, command, address, jsonOutput), func(t *testing.T) {
22+
setCLIUserConfigRoot(t)
23+
clearProviderEnv(t)
24+
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
25+
path := filepath.Join(t.TempDir(), "config.json")
26+
writeProviderOnboardingConfig(t, path, config.FileConfig{
27+
ActiveProvider: "other",
28+
Providers: []config.ProviderProfile{
29+
{Name: "work", CatalogID: "openai", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://user.example/v1", Model: "user-model", APIKeyStored: true},
30+
{Name: "other", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://other.example/v1", Model: "other-model"},
31+
},
32+
})
33+
workspace := t.TempDir()
34+
if source == "project" {
35+
writeProviderOnboardingConfig(t, filepath.Join(workspace, ".zero", "config.json"), config.FileConfig{Providers: []config.ProviderProfile{
36+
{Name: "openai", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://project.example/v1", Model: "project-model"},
37+
}})
38+
} else {
39+
t.Setenv("OPENAI_API_KEY", "env-key")
40+
t.Setenv("OPENAI_BASE_URL", "https://env.example/v1")
41+
}
42+
resolveOptions, err := config.DefaultResolveOptions(workspace)
43+
if err != nil {
44+
t.Fatal(err)
45+
}
46+
resolveOptions.UserConfigPath = path
47+
names, err := config.ResolveProviderSourceNames(resolveOptions)
48+
if _, lookup := config.LookupProviderName(names, "openai"); err != nil || lookup != config.ProviderNameExact {
49+
t.Fatalf("fixture must contain a concrete openai row: names=%v err=%v", names, err)
50+
}
51+
before, err := os.ReadFile(path)
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
store, err := config.ProviderKeyStoreAt(filepath.Dir(path))
56+
if err != nil {
57+
t.Fatal(err)
58+
}
59+
if err := store.Set("work", "user-key"); err != nil {
60+
t.Fatal(err)
61+
}
62+
deps := providerSetupDeps(path)
63+
deps.getwd = func() (string, error) { return workspace, nil }
64+
args := []string{"providers", command, address}
65+
if command == "rename" {
66+
args = append(args, "renamed")
67+
}
68+
if jsonOutput {
69+
args = append(args, "--json")
70+
}
71+
var stdout, stderr bytes.Buffer
72+
code := runWithDeps(args, &stdout, &stderr, deps)
73+
if address == "work" {
74+
if code != exitSuccess || stdout.Len() == 0 {
75+
t.Fatalf("exact saved row rejected: exit=%d stderr=%q", code, stderr.String())
76+
}
77+
cfg := readFileConfig(t, path)
78+
switch command {
79+
case "use":
80+
if cfg.ActiveProvider != "work" {
81+
t.Fatalf("activeProvider = %q, want work", cfg.ActiveProvider)
82+
}
83+
case "remove":
84+
if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "other" {
85+
t.Fatal("exact saved row was not removed")
86+
}
87+
case "rename":
88+
if cfg.Providers[0].Name != "renamed" {
89+
t.Fatal("exact saved row was not renamed")
90+
}
91+
}
92+
return
93+
}
94+
if code != exitCrash || stdout.Len() != 0 || !strings.Contains(stderr.String(), "isn't saved in config.json") {
95+
t.Errorf("concrete row should report ownership error, exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
96+
}
97+
after, err := os.ReadFile(path)
98+
if err != nil || !bytes.Equal(before, after) {
99+
t.Errorf("concrete row request changed user config: %v", err)
100+
}
101+
key, ok, err := store.Get("work")
102+
if err != nil || !ok || key != "user-key" {
103+
t.Errorf("concrete row request changed user credential: present=%t error=%v", ok, err)
104+
}
105+
})
106+
}
107+
}
108+
}
109+
}
110+
}
111+
16112
func TestProviderMutationsRejectProjectCaseSibling(t *testing.T) {
17113
for _, command := range []string{"remove", "rename"} {
18114
t.Run(command, func(t *testing.T) {

‎internal/config/writer.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ func RepairUnnamedProvider(path string, replacement string) (result FileConfig,
180180
name = "openai"
181181
}
182182
}
183+
if cfg.Providers[unnamed].APIKeyStored {
184+
if sibling, collides := conflictingProviderRowName(*cfg, unnamed, legacyName); collides && sibling != legacyName {
185+
return fmt.Errorf("the unnamed provider's stored credential uses identity %q, which conflicts with persisted provider %q; manually rename the sibling %q in config.json and reconcile any of its credential references before retrying `zero providers repair-config`; --name cannot safely move the stored credential", legacyName, sibling, sibling)
186+
}
187+
}
183188
if conflict, collides := conflictingProviderRowName(*cfg, unnamed, name); collides {
184189
if explicit {
185190
return fmt.Errorf(

‎internal/config/writer_test.go‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1590,6 +1590,27 @@ func TestRepairUnnamedProviderAllowsExplicitUniqueName(t *testing.T) {
15901590
}
15911591
}
15921592

1593+
func TestRepairUnnamedStoredProviderCaseCollisionExplainsManualRecovery(t *testing.T) {
1594+
path := filepath.Join(t.TempDir(), "config.json")
1595+
before := writeConfigFixture(t, path, FileConfig{
1596+
ActiveProvider: "legacy",
1597+
Providers: []ProviderProfile{
1598+
{APIKeyStored: true},
1599+
{Name: "LEGACY"},
1600+
},
1601+
}, 0o600)
1602+
for _, name := range []string{"", "work", "Legacy"} {
1603+
_, _, err := RepairUnnamedProvider(path, name)
1604+
if err == nil || !strings.Contains(err.Error(), `manually rename the sibling "LEGACY" in config.json`) {
1605+
t.Fatalf("repair with name %q must explain the manual recovery: %v", name, err)
1606+
}
1607+
after, readErr := os.ReadFile(path)
1608+
if readErr != nil || !bytes.Equal(before, after) {
1609+
t.Fatal("refused repair changed config")
1610+
}
1611+
}
1612+
}
1613+
15931614
func TestEnsureCatalogProviderValidatesBeforeExistingProfileShortcut(t *testing.T) {
15941615
path := filepath.Join(t.TempDir(), "config.json")
15951616
before := writeConfigFixture(t, path, FileConfig{Providers: []ProviderProfile{{Name: "xai"}, {Name: "XAI"}}}, 0o600)

‎internal/tui/command_center.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -633,11 +633,11 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
633633
switch {
634634
case err != nil:
635635
persistErr = err
636-
persistNote = "\nNote: the switch applies to this session, but config.json could not be read: " + redaction.RedactString(err.Error(), redaction.Options{})
636+
persistNote = "\nNote: the switch applies to this session, but config.json could not be read."
637637
case owner.UserBacked:
638638
if _, err := config.SetActiveProviderModel(m.userConfigPath, owner.PersistedName, target.Model); err != nil {
639639
persistErr = err
640-
persistNote = "\nNote: the switch applies to this session, but config.json was not updated: " + redaction.RedactString(err.Error(), redaction.Options{})
640+
persistNote = "\nNote: the switch applies to this session, but config.json was not updated."
641641
} else {
642642
persisted = true
643643
// Reconcile the in-memory list the manager and picker read from,

‎internal/tui/command_center_test.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ func TestSwitchProviderModelReportsPersistenceFailures(t *testing.T) {
207207
if !strings.Contains(status, "config.json could not be read") {
208208
t.Fatalf("status = %q, want a persistence note", status)
209209
}
210+
if strings.Count(status, err.Error()) != 1 {
211+
t.Fatal("persistence error must appear exactly once in the switch status")
212+
}
210213
// The session did switch, which is exactly why the note has to be there.
211214
if next.providerName != "OpenAI" {
212215
t.Fatalf("providerName = %q, want OpenAI", next.providerName)
@@ -227,6 +230,9 @@ func TestSwitchProviderModelReportsPersistenceFailures(t *testing.T) {
227230
if !strings.Contains(status, "config.json was not updated") {
228231
t.Fatalf("status = %q, want the active-provider persistence note", status)
229232
}
233+
if strings.Count(status, err.Error()) != 1 {
234+
t.Fatal("persistence error must appear exactly once in the switch status")
235+
}
230236
})
231237

232238
t.Run("env-derived provider stays silent", func(t *testing.T) {

‎internal/tui/model_test.go‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,26 +1277,27 @@ func TestResumeAfterDeletingLiveProjectRow(t *testing.T) {
12771277
m.providerWizard = nil
12781278
}
12791279
wantProvider, wantModel, wantRemoved := m.providerName, m.modelName, m.removedLiveRow
1280-
liveClient := m.provider
1280+
liveClient, liveProfile := m.provider, m.providerProfile
12811281
if tc.sameSession {
12821282
target = previous
12831283
}
12841284
next, message := m.handleResumeCommand(target.SessionID)
12851285
if message != "" || next.activeSession.SessionID != target.SessionID {
12861286
t.Fatalf("resume failed: %s", message)
12871287
}
1288-
if next.provider != liveClient || len(built) != 0 {
1289-
t.Fatal("resume must keep the live client without building another provider")
1290-
}
1291-
if next.providerProfile.Name != "WORK" || next.providerProfile.BaseURL != "https://project.example.com/v1" || next.providerProfile.Model != "project-model" || next.providerProfile.APIKey != "sk-project" {
1292-
t.Fatal("resume changed the live project provider profile")
1288+
// Resume loads history, not a new provider. Labels and retained row
1289+
// ownership must still describe the client that answers the next turn.
1290+
if next.provider != liveClient || len(built) != 0 || !reflect.DeepEqual(next.providerProfile, liveProfile) {
1291+
t.Fatal("resume changed the live client or its endpoint/credential profile")
12931292
}
12941293
if next.removedLiveRow != wantRemoved || next.providerName != wantProvider || next.modelName != wantModel || next.activeProviderRowName() != wantProvider {
12951294
t.Fatalf("resume identity = (%q, %q, %q, %q), want (%q, %q, %q, %q)", next.removedLiveRow, next.providerName, next.modelName, next.activeProviderRowName(), wantRemoved, wantProvider, wantModel, wantProvider)
12961295
}
1297-
summary := plainRender(t, transcriptText(next.transcript))
1298-
if !tc.sameSession && (!strings.Contains(summary, "project-model") || !strings.Contains(summary, "(recorded: resumed-model)")) {
1299-
t.Fatalf("resume summary must distinguish the live model from the recorded model: %s", summary)
1296+
if !transcriptContains(next.transcript, "model: project-model") || !transcriptContains(next.transcript, "provider: WORK") {
1297+
t.Fatal("resume summary does not describe the live project client")
1298+
}
1299+
if !tc.sameSession && !transcriptContains(next.transcript, "(recorded: resumed-model)") {
1300+
t.Fatal("resume summary does not distinguish the recorded model from the live model")
13001301
}
13011302
assertUserRowUntouched(t, next, before)
13021303
})

‎internal/tui/session.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,8 @@ func (m model) handleResumeCommand(args string) (model, string) {
236236
m.activeSession = *session
237237
m.pendingSessionTitle = ""
238238
m.sessionEvents = append([]sessions.Event{}, events...)
239-
// Resume restores history, not the client. Keep the live identity, including
240-
// a removed provider row, until an explicit provider switch replaces it.
239+
// Resume replaces conversation history, not the live client. Keep its
240+
// identity (including a removed row) and model until an actual provider switch.
241241
if m.providerName == "" {
242242
m.removedLiveRow = ""
243243
m.providerName = session.Provider

0 commit comments

Comments
 (0)