返回 DeepSeek-Reasonix
setup_manager_test.go
根目录 / internal / cli / setup_manager_test.go
1 package cli
2
3 import (
4 "bufio"
5 "bytes"
6 "errors"
7 "os"
8 "path/filepath"
9 "reflect"
10 "strings"
11 "testing"
12
13 "reasonix/internal/config"
14 )
15
16 func setupTestConfig() *config.Config {
17 cfg := config.Default()
18 cfg.Providers = []config.ProviderEntry{
19 {Name: "desktop-provider", Kind: "openai", BaseURL: "https://desktop.example/v1", Model: "desktop-model", APIKeyEnv: "SHARED_API_KEY"},
20 {Name: "cli-provider", Kind: "openai", BaseURL: "https://cli.example/v1", Model: "cli-model"},
21 }
22 cfg.DefaultModel = "desktop-provider"
23 cfg.Agent.Temperature = 0.77
24 cfg.Desktop.ProviderAccess = []string{"desktop-provider", "cli-provider"}
25 return cfg
26 }
27
28 func TestProviderSetupSessionAddPreservesExistingProvidersAndSettings(t *testing.T) {
29 cfg := setupTestConfig()
30 s := newProviderSetupSession(cfg)
31 added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_RELAY_API_KEY"}
32 if err := s.upsert([]config.ProviderEntry{added}); err != nil {
33 t.Fatal(err)
34 }
35 if len(cfg.Providers) != 3 || cfg.Providers[0].Name != "desktop-provider" || cfg.Providers[1].Name != "cli-provider" {
36 t.Fatalf("existing providers were not preserved: %+v", cfg.Providers)
37 }
38 if cfg.DefaultModel != "desktop-provider" || cfg.Agent.Temperature != 0.77 {
39 t.Fatalf("unrelated settings changed: default=%q temperature=%v", cfg.DefaultModel, cfg.Agent.Temperature)
40 }
41 s.addProviderAccess([]config.ProviderEntry{added})
42 if got := cfg.Desktop.ProviderAccess; !containsString(got, "desktop-provider") || !containsString(got, "cli-provider") || !containsString(got, "grok-relay") {
43 t.Fatalf("desktop provider access was not preserved and extended: %v", got)
44 }
45 }
46
47 func TestProviderSetupSessionEditPreservesSiblingAndAdvancedFields(t *testing.T) {
48 cfg := setupTestConfig()
49 cfg.Providers[0].Headers = map[string]string{"X-Relay": "yes"}
50 s := newProviderSetupSession(cfg)
51 edited := cfg.Providers[0]
52 edited.Models = []string{"desktop-model", "desktop-model-2"}
53 edited.Model = ""
54 if err := s.upsert([]config.ProviderEntry{edited}); err != nil {
55 t.Fatal(err)
56 }
57 if cfg.Providers[1].Name != "cli-provider" {
58 t.Fatalf("sibling provider changed: %+v", cfg.Providers[1])
59 }
60 if cfg.Providers[0].Headers["X-Relay"] != "yes" {
61 t.Fatalf("advanced provider fields were lost: %+v", cfg.Providers[0])
62 }
63 }
64
65 func TestProviderSetupSessionAddRejectsExistingProviderWithoutChangingIt(t *testing.T) {
66 cfg := setupTestConfig()
67 baseURL := "https://desktop.example/v1"
68 name := providerSlug("custom", baseURL)
69 cfg.Providers[0].Name = name
70 cfg.DefaultModel = name
71 cfg.Providers[0].Headers = map[string]string{"X-Relay": "yes"}
72 cfg.Providers[0].NoProxy = true
73 want := cfg.Providers[0]
74 s := newProviderSetupSession(cfg)
75 replacement := config.ProviderEntry{
76 Name: providerSlug("custom", baseURL), Kind: "openai", BaseURL: baseURL,
77 Model: "new-model", APIKeyEnv: "SHARED_API_KEY",
78 }
79 if err := s.add([]config.ProviderEntry{replacement}); err == nil {
80 t.Fatal("adding an existing provider should require the edit flow")
81 }
82 if !reflect.DeepEqual(cfg.Providers[0], want) {
83 t.Fatalf("existing provider changed after rejected add:\n got: %+v\nwant: %+v", cfg.Providers[0], want)
84 }
85 }
86
87 func TestProviderSetupSessionRemovalIsExplicitAndRepairsDefault(t *testing.T) {
88 cfg := setupTestConfig()
89 s := newProviderSetupSession(cfg)
90 if len(cfg.Providers) != 2 {
91 t.Fatal("provider changed before explicit remove")
92 }
93 if err := s.remove("desktop-provider"); err != nil {
94 t.Fatal(err)
95 }
96 if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "cli-provider" {
97 t.Fatalf("remove result = %+v", cfg.Providers)
98 }
99 if cfg.DefaultModel != "cli-provider" {
100 t.Fatalf("default fallback = %q, want cli-provider", cfg.DefaultModel)
101 }
102 if containsString(cfg.Desktop.ProviderAccess, "desktop-provider") || !containsString(cfg.Desktop.ProviderAccess, "cli-provider") {
103 t.Fatalf("desktop provider access was not cleaned safely: %v", cfg.Desktop.ProviderAccess)
104 }
105 }
106
107 func TestProviderSetupSessionAddPromotesDefaultWhenCurrentDefaultUnusable(t *testing.T) {
108 isolateUserConfig(t)
109 cfg := setupTestConfig()
110 // desktop-provider names SHARED_API_KEY, which is neither stored nor staged.
111 s := newProviderSetupSession(cfg)
112 added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_RELAY_API_KEY"}
113 if err := s.add([]config.ProviderEntry{added}); err != nil {
114 t.Fatal(err)
115 }
116 if err := s.setCredential("GROK_RELAY_API_KEY", "staged-secret"); err != nil {
117 t.Fatal(err)
118 }
119 s.promoteDefaultToNewProviders([]config.ProviderEntry{added})
120 if cfg.DefaultModel != "grok-relay" {
121 t.Fatalf("default = %q, want promotion to grok-relay", cfg.DefaultModel)
122 }
123 }
124
125 func TestProviderSetupSessionAddKeepsUsableDefault(t *testing.T) {
126 isolateUserConfig(t)
127 added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_RELAY_API_KEY"}
128
129 // cli-provider needs no key, so the default is usable and must not move.
130 cfg := setupTestConfig()
131 cfg.DefaultModel = "cli-provider"
132 s := newProviderSetupSession(cfg)
133 if err := s.add([]config.ProviderEntry{added}); err != nil {
134 t.Fatal(err)
135 }
136 if err := s.setCredential("GROK_RELAY_API_KEY", "staged-secret"); err != nil {
137 t.Fatal(err)
138 }
139 s.promoteDefaultToNewProviders([]config.ProviderEntry{added})
140 if cfg.DefaultModel != "cli-provider" {
141 t.Fatalf("keyless default was hijacked: %q", cfg.DefaultModel)
142 }
143
144 // A default whose key was staged earlier in this session is usable too.
145 cfg = setupTestConfig()
146 s = newProviderSetupSession(cfg)
147 if err := s.setCredential("SHARED_API_KEY", "staged-secret"); err != nil {
148 t.Fatal(err)
149 }
150 if err := s.add([]config.ProviderEntry{added}); err != nil {
151 t.Fatal(err)
152 }
153 s.promoteDefaultToNewProviders([]config.ProviderEntry{added})
154 if cfg.DefaultModel != "desktop-provider" {
155 t.Fatalf("staged-key default was hijacked: %q", cfg.DefaultModel)
156 }
157 }
158
159 func TestFirstRunAddCustomProviderPersistsPromotedDefault(t *testing.T) {
160 isolateUserConfig(t)
161 path := config.UserConfigPath()
162 if _, err := os.Stat(path); !os.IsNotExist(err) {
163 t.Fatalf("first-run precondition: config already exists at %s", path)
164 }
165 cfg := config.LoadForEdit(path)
166 s := newProviderSetupSessionForPath(cfg, path)
167 added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_RELAY_API_KEY"}
168 if err := s.add([]config.ProviderEntry{added}); err != nil {
169 t.Fatal(err)
170 }
171 s.addProviderAccess([]config.ProviderEntry{added})
172 if err := s.setCredential("GROK_RELAY_API_KEY", "staged-secret"); err != nil {
173 t.Fatal(err)
174 }
175 s.promoteDefaultToNewProviders([]config.ProviderEntry{added})
176 if _, err := commitProviderSetupSession(s, path); err != nil {
177 t.Fatal(err)
178 }
179 got := config.LoadForEdit(path)
180 if got.DefaultModel != "grok-relay" {
181 t.Fatalf("persisted default = %q, want grok-relay (built-in default has no key)", got.DefaultModel)
182 }
183 if _, ok := got.ResolveModel(got.DefaultModel); !ok {
184 t.Fatalf("persisted default %q does not resolve", got.DefaultModel)
185 }
186 }
187
188 func TestProviderSetupSessionUpsertRepairsDanglingDefaultRef(t *testing.T) {
189 isolateUserConfig(t)
190 cfg := setupTestConfig()
191 cfg.DefaultModel = "cli-provider/cli-model"
192 s := newProviderSetupSession(cfg)
193 refreshed := cfg.Providers[1]
194 refreshed.Model = ""
195 refreshed.Models = []string{"cli-model-2"}
196 if err := s.upsert([]config.ProviderEntry{refreshed}); err != nil {
197 t.Fatal(err)
198 }
199 if cfg.DefaultModel != "cli-provider" {
200 t.Fatalf("dangling default = %q, want repair to cli-provider", cfg.DefaultModel)
201 }
202 if _, ok := cfg.ResolveModel(cfg.DefaultModel); !ok {
203 t.Fatalf("repaired default %q does not resolve", cfg.DefaultModel)
204 }
205 }
206
207 func TestProviderSetupSessionAddAccessRespectsExplicitEmptyList(t *testing.T) {
208 cfg := setupTestConfig()
209 cfg.Desktop.ProviderAccess = nil
210 s := newProviderSetupSession(cfg)
211 s.accessDeclared = true
212 added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_API_KEY"}
213 s.addProviderAccess([]config.ProviderEntry{added})
214 if got := cfg.Desktop.ProviderAccess; len(got) != 1 || got[0] != "grok-relay" {
215 t.Fatalf("explicit empty access should enable only the added provider, got %v", got)
216 }
217 }
218
219 func TestProviderSetupSessionAddAccessSeedsUndeclaredLegacyProviders(t *testing.T) {
220 cfg := setupTestConfig()
221 cfg.Desktop.ProviderAccess = nil
222 s := newProviderSetupSession(cfg)
223 added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_API_KEY"}
224 s.addProviderAccess([]config.ProviderEntry{added, added})
225 if got := cfg.Desktop.ProviderAccess; !containsString(got, "cli-provider") || !containsString(got, "grok-relay") {
226 t.Fatalf("undeclared legacy access should preserve configured siblings and add the new provider: %v", got)
227 }
228 count := 0
229 for _, name := range cfg.Desktop.ProviderAccess {
230 if name == "grok-relay" {
231 count++
232 }
233 }
234 if count != 1 {
235 t.Fatalf("added provider access should be deduplicated: %v", cfg.Desktop.ProviderAccess)
236 }
237 }
238
239 func TestLocalProviderSetupAccessOnlySeedsProjectProviders(t *testing.T) {
240 isolateUserConfig(t)
241 path := filepath.Join(t.TempDir(), "reasonix.toml")
242 if err := os.WriteFile(path, []byte(`
243 [[providers]]
244 name = "project-relay"
245 kind = "openai"
246 base_url = "https://project.example/v1"
247 model = "project-model"
248 api_key_env = ""
249 `), 0o600); err != nil {
250 t.Fatal(err)
251 }
252 cfg := config.LoadForEdit(path)
253 s := newProviderSetupSessionForPath(cfg, path)
254 added := config.ProviderEntry{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5"}
255 if err := s.add([]config.ProviderEntry{added}); err != nil {
256 t.Fatal(err)
257 }
258 s.addProviderAccess([]config.ProviderEntry{added})
259 got := cfg.Desktop.ProviderAccess
260 if !containsString(got, "project-relay") || !containsString(got, "grok-relay") {
261 t.Fatalf("project provider access = %v, want existing and added project providers", got)
262 }
263 for _, forbidden := range []string{"deepseek", "deepseek-flash", "deepseek-pro"} {
264 if containsString(got, forbidden) {
265 t.Fatalf("project provider access unexpectedly enabled built-in %q: %v", forbidden, got)
266 }
267 }
268 if err := cfg.SaveTo(path); err != nil {
269 t.Fatal(err)
270 }
271 reloaded := config.LoadForEditWithoutCredentials(path)
272 if got := reloaded.Desktop.ProviderAccess; !containsString(got, "project-relay") || !containsString(got, "grok-relay") {
273 t.Fatalf("reloaded project provider access = %v", got)
274 } else {
275 for _, forbidden := range []string{"deepseek", "deepseek-flash", "deepseek-pro"} {
276 if containsString(got, forbidden) {
277 t.Fatalf("reloaded project access unexpectedly enabled %q: %v", forbidden, got)
278 }
279 }
280 }
281 }
282
283 func TestNewProviderSetupSessionDetectsExplicitProviderAccess(t *testing.T) {
284 path := filepath.Join(t.TempDir(), "config.toml")
285 if err := os.WriteFile(path, []byte("[desktop]\nprovider_access = []\n"), 0o600); err != nil {
286 t.Fatal(err)
287 }
288 s := newProviderSetupSessionForPath(setupTestConfig(), path)
289 if !s.accessDeclared {
290 t.Fatal("explicit empty desktop.provider_access was treated as undeclared")
291 }
292 }
293
294 func TestProviderSetupSessionPersistsEmptyAccessAfterLastRemoval(t *testing.T) {
295 isolateUserConfig(t)
296 cfg := setupTestConfig()
297 cfg.Desktop.ProviderAccess = []string{"desktop-provider"}
298 s := newProviderSetupSession(cfg)
299 s.accessDeclared = true
300 if err := s.remove("desktop-provider"); err != nil {
301 t.Fatal(err)
302 }
303 if cfg.Desktop.ProviderAccess == nil || len(cfg.Desktop.ProviderAccess) != 0 {
304 t.Fatalf("last removal should retain an explicit empty access list: %#v", cfg.Desktop.ProviderAccess)
305 }
306 path := config.UserConfigPath()
307 if err := cfg.SaveTo(path); err != nil {
308 t.Fatal(err)
309 }
310 body, err := os.ReadFile(path)
311 if err != nil {
312 t.Fatal(err)
313 }
314 if !strings.Contains(string(body), "provider_access = []") {
315 t.Fatalf("explicit empty provider access was omitted from saved config:\n%s", body)
316 }
317 }
318
319 func TestProviderSetupSessionAllowsSharedCredentialName(t *testing.T) {
320 cfg := setupTestConfig()
321 s := newProviderSetupSession(cfg)
322 shared := config.ProviderEntry{Name: "second-relay", Kind: "openai", BaseURL: "https://other.example/v1", Model: "grok-4.5", APIKeyEnv: "SHARED_API_KEY"}
323 if err := s.upsert([]config.ProviderEntry{shared}); err != nil {
324 t.Fatalf("intentional shared api_key_env should be valid: %v", err)
325 }
326 if err := s.setCredential("SHARED_API_KEY", "shared-secret"); err != nil {
327 t.Fatal(err)
328 }
329 if got := s.credentialLines(); len(got) != 1 || got[0] != "SHARED_API_KEY=shared-secret" {
330 t.Fatalf("credential lines = %v", got)
331 }
332 }
333
334 func TestProviderSetupSessionCancelDoesNotWriteFiles(t *testing.T) {
335 dir := t.TempDir()
336 path := filepath.Join(dir, "config.toml")
337 original := []byte("default_model = \"keep\"\n")
338 if err := os.WriteFile(path, original, 0o600); err != nil {
339 t.Fatal(err)
340 }
341 cfg := setupTestConfig()
342 s := newProviderSetupSession(cfg)
343 if err := s.upsert([]config.ProviderEntry{{Name: "staged", Kind: "openai", BaseURL: "https://staged.example/v1", Model: "staged-model", APIKeyEnv: "STAGED_API_KEY"}}); err != nil {
344 t.Fatal(err)
345 }
346 if err := s.setCredential("STAGED_API_KEY", "not-written"); err != nil {
347 t.Fatal(err)
348 }
349 got, err := os.ReadFile(path)
350 if err != nil {
351 t.Fatal(err)
352 }
353 if string(got) != string(original) {
354 t.Fatalf("staging changed config on disk: %q", got)
355 }
356 }
357
358 func TestPromptOptionalAPIKeyEnvNameAllowsNoAuthProvider(t *testing.T) {
359 var out bytes.Buffer
360 got := promptOptionalAPIKeyEnvName(bufio.NewScanner(strings.NewReader("\n")), &out, "API key variable", "")
361 if got != "" {
362 t.Fatalf("optional API key variable = %q, want empty", got)
363 }
364 }
365
366 func TestProviderSetupSessionSummaryReportsChanges(t *testing.T) {
367 cfg := setupTestConfig()
368 s := newProviderSetupSession(cfg)
369 if err := s.remove("cli-provider"); err != nil {
370 t.Fatal(err)
371 }
372 if err := s.upsert([]config.ProviderEntry{{Name: "grok-relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "grok-4.5", APIKeyEnv: "GROK_API_KEY"}}); err != nil {
373 t.Fatal(err)
374 }
375 if err := s.setCredential("GROK_API_KEY", "secret"); err != nil {
376 t.Fatal(err)
377 }
378 text := strings.Join(s.summary(), "\n")
379 for _, want := range []string{"grok-relay", "cli-provider", "1"} {
380 if !strings.Contains(text, want) {
381 t.Fatalf("summary %q missing %q", text, want)
382 }
383 }
384 }
385
386 func TestProviderSetupSummaryDoesNotLeakCredential(t *testing.T) {
387 s := newProviderSetupSession(setupTestConfig())
388 const secret = "rk_test_ABCDEF1234567890"
389 if err := s.setCredential("GROK_API_KEY", secret); err != nil {
390 t.Fatal(err)
391 }
392 text := strings.Join(s.summary(), "\n")
393 if strings.Contains(text, secret) {
394 t.Fatalf("summary leaked credential: %q", text)
395 }
396 }
397
398 func TestProviderSetupOperationReplayPreservesConcurrentUnrelatedChanges(t *testing.T) {
399 isolateUserConfig(t)
400 path := config.UserConfigPath()
401 initial := setupTestConfig()
402 if err := initial.SaveTo(path); err != nil {
403 t.Fatal(err)
404 }
405
406 working := config.LoadForEdit(path)
407 s := newProviderSetupSessionForPath(working, path)
408 s.setLanguage("en")
409 edited := working.Providers[0]
410 edited.Models = []string{"desktop-model", "desktop-model-2"}
411 edited.Model = ""
412 if err := s.upsert([]config.ProviderEntry{edited}); err != nil {
413 t.Fatal(err)
414 }
415
416 external := config.LoadForEdit(path)
417 external.Agent.Temperature = 0.23
418 external.DefaultModel = "cli-provider"
419 external.Providers[1].Headers = map[string]string{"X-External": "keep"}
420 if err := external.SaveTo(path); err != nil {
421 t.Fatal(err)
422 }
423
424 if _, err := commitProviderSetupSession(s, path); err != nil {
425 t.Fatalf("commitProviderSetupSession: %v", err)
426 }
427 got := config.LoadForEdit(path)
428 if got.Agent.Temperature != 0.23 || got.DefaultModel != "cli-provider" || got.Language != "en" {
429 t.Fatalf("scalar replay lost data: temperature=%v default=%q language=%q", got.Agent.Temperature, got.DefaultModel, got.Language)
430 }
431 if got.Providers[1].Headers["X-External"] != "keep" {
432 t.Fatalf("concurrent sibling provider edit was lost: %+v", got.Providers[1])
433 }
434 if models := got.Providers[0].ModelList(); !containsString(models, "desktop-model-2") {
435 t.Fatalf("setup provider edit was not replayed: %v", models)
436 }
437 }
438
439 func TestProviderSetupCommitWithNoOperationsDoesNotRewriteConfig(t *testing.T) {
440 isolateUserConfig(t)
441 path := config.UserConfigPath()
442 initial := setupTestConfig()
443 if err := initial.SaveTo(path); err != nil {
444 t.Fatal(err)
445 }
446 before, err := os.ReadFile(path)
447 if err != nil {
448 t.Fatal(err)
449 }
450 s := newProviderSetupSessionForPath(config.LoadForEdit(path), path)
451 written, err := commitProviderSetupSession(s, path)
452 if err != nil {
453 t.Fatal(err)
454 }
455 if written {
456 t.Fatal("no-op setup reported a config write")
457 }
458 after, err := os.ReadFile(path)
459 if err != nil {
460 t.Fatal(err)
461 }
462 if string(after) != string(before) {
463 t.Fatal("no-op setup rewrote config")
464 }
465 }
466
467 func TestProviderSetupOperationReplayRejectsConcurrentSameProviderEdit(t *testing.T) {
468 isolateUserConfig(t)
469 path := config.UserConfigPath()
470 initial := setupTestConfig()
471 if err := initial.SaveTo(path); err != nil {
472 t.Fatal(err)
473 }
474
475 working := config.LoadForEdit(path)
476 s := newProviderSetupSessionForPath(working, path)
477 edited := working.Providers[0]
478 edited.Model = "setup-model"
479 edited.Models = nil
480 if err := s.upsert([]config.ProviderEntry{edited}); err != nil {
481 t.Fatal(err)
482 }
483
484 external := config.LoadForEdit(path)
485 external.Providers[0].BaseURL = "https://external.example/v1"
486 if err := external.SaveTo(path); err != nil {
487 t.Fatal(err)
488 }
489
490 _, err := commitProviderSetupSession(s, path)
491 var conflict *providerSetupConflictError
492 if !errors.As(err, &conflict) || !strings.Contains(conflict.field, "desktop-provider") {
493 t.Fatalf("commit conflict = %v, want provider conflict", err)
494 }
495 got := config.LoadForEdit(path)
496 if got.Providers[0].BaseURL != "https://external.example/v1" || got.Providers[0].Model != "desktop-model" {
497 t.Fatalf("conflicting setup edit changed disk config: %+v", got.Providers[0])
498 }
499 }
500
501 func TestProviderSetupSaveConflictDoesNotWriteStagedCredentials(t *testing.T) {
502 isolateUserConfig(t)
503 path := config.UserConfigPath()
504 initial := setupTestConfig()
505 if err := initial.SaveTo(path); err != nil {
506 t.Fatal(err)
507 }
508
509 working := config.LoadForEdit(path)
510 s := newProviderSetupSessionForPath(working, path)
511 edited := working.Providers[0]
512 edited.Model = "setup-model"
513 edited.Models = nil
514 if err := s.upsert([]config.ProviderEntry{edited}); err != nil {
515 t.Fatal(err)
516 }
517 if err := s.setCredential("STAGED_API_KEY", "must-not-be-written"); err != nil {
518 t.Fatal(err)
519 }
520
521 external := config.LoadForEdit(path)
522 external.Providers[0].BaseURL = "https://external.example/v1"
523 if err := external.SaveTo(path); err != nil {
524 t.Fatal(err)
525 }
526
527 readEnd, writeEnd, err := os.Pipe()
528 if err != nil {
529 t.Fatal(err)
530 }
531 oldStdin := os.Stdin
532 os.Stdin = readEnd
533 t.Cleanup(func() {
534 os.Stdin = oldStdin
535 _ = readEnd.Close()
536 _ = writeEnd.Close()
537 })
538 if _, err := writeEnd.WriteString("\n"); err != nil {
539 t.Fatal(err)
540 }
541 if err := writeEnd.Close(); err != nil {
542 t.Fatal(err)
543 }
544
545 if rc := saveProviderSetupSession(s, path, config.UserCredentialsPath()); rc != 1 {
546 t.Fatalf("saveProviderSetupSession return code = %d, want 1", rc)
547 }
548 if config.CredentialStored("STAGED_API_KEY") {
549 t.Fatal("config conflict wrote staged credentials")
550 }
551 }
552
553 func TestProviderSetupOperationReplayRejectsConcurrentDefaultEdit(t *testing.T) {
554 isolateUserConfig(t)
555 path := config.UserConfigPath()
556 initial := setupTestConfig()
557 if err := initial.SaveTo(path); err != nil {
558 t.Fatal(err)
559 }
560 working := config.LoadForEdit(path)
561 s := newProviderSetupSessionForPath(working, path)
562 if err := s.setDefaultModel("cli-provider"); err != nil {
563 t.Fatal(err)
564 }
565 external := config.LoadForEdit(path)
566 external.DefaultModel = "desktop-provider/desktop-model"
567 if err := external.SaveTo(path); err != nil {
568 t.Fatal(err)
569 }
570 _, err := commitProviderSetupSession(s, path)
571 var conflict *providerSetupConflictError
572 if !errors.As(err, &conflict) || conflict.field != "default_model" {
573 t.Fatalf("commit conflict = %v, want default_model conflict", err)
574 }
575 if got := config.LoadForEdit(path).DefaultModel; got != "desktop-provider/desktop-model" {
576 t.Fatalf("external default_model was overwritten: %q", got)
577 }
578 }
579
580 func TestProviderSetupOperationReplayMergesConcurrentAccessAddition(t *testing.T) {
581 isolateUserConfig(t)
582 path := config.UserConfigPath()
583 initial := setupTestConfig()
584 if err := initial.SaveTo(path); err != nil {
585 t.Fatal(err)
586 }
587
588 working := config.LoadForEdit(path)
589 s := newProviderSetupSessionForPath(working, path)
590 setupProvider := config.ProviderEntry{Name: "setup-provider", Kind: "openai", BaseURL: "https://setup.example/v1", Model: "setup-model"}
591 if err := s.add([]config.ProviderEntry{setupProvider}); err != nil {
592 t.Fatal(err)
593 }
594 s.addProviderAccess([]config.ProviderEntry{setupProvider})
595
596 external := config.LoadForEdit(path)
597 externalProvider := config.ProviderEntry{Name: "external-provider", Kind: "openai", BaseURL: "https://external.example/v1", Model: "external-model"}
598 if err := external.UpsertProvider(externalProvider); err != nil {
599 t.Fatal(err)
600 }
601 external.Desktop.ProviderAccess = append(external.Desktop.ProviderAccess, externalProvider.Name)
602 if err := external.SaveTo(path); err != nil {
603 t.Fatal(err)
604 }
605
606 if _, err := commitProviderSetupSession(s, path); err != nil {
607 t.Fatalf("commitProviderSetupSession: %v", err)
608 }
609 got := config.LoadForEdit(path)
610 for _, name := range []string{setupProvider.Name, externalProvider.Name} {
611 if _, ok := got.Provider(name); !ok || !containsString(got.Desktop.ProviderAccess, name) {
612 t.Fatalf("provider/access %q missing after replay: providers=%v access=%v", name, got.Providers, got.Desktop.ProviderAccess)
613 }
614 }
615 }
616
617 func TestProviderSetupOperationReplayRejectsConcurrentAccessDeclaration(t *testing.T) {
618 isolateUserConfig(t)
619 path := config.UserConfigPath()
620 initial := setupTestConfig()
621 initial.Desktop.ProviderAccess = nil
622 if err := initial.SaveTo(path); err != nil {
623 t.Fatal(err)
624 }
625
626 working := config.LoadForEdit(path)
627 s := newProviderSetupSessionForPath(working, path)
628 added := config.ProviderEntry{Name: "setup-provider", Kind: "openai", BaseURL: "https://setup.example/v1", Model: "setup-model"}
629 if err := s.add([]config.ProviderEntry{added}); err != nil {
630 t.Fatal(err)
631 }
632 s.addProviderAccess([]config.ProviderEntry{added})
633
634 external := config.LoadForEdit(path)
635 external.Desktop.ProviderAccess = []string{}
636 if err := external.SaveTo(path); err != nil {
637 t.Fatal(err)
638 }
639
640 _, err := commitProviderSetupSession(s, path)
641 var conflict *providerSetupConflictError
642 if !errors.As(err, &conflict) || conflict.field != "desktop.provider_access" {
643 t.Fatalf("commit conflict = %v, want provider_access declaration conflict", err)
644 }
645 declared, err := config.DesktopProviderAccessDeclared(path)
646 if err != nil {
647 t.Fatal(err)
648 }
649 got := config.LoadForEdit(path)
650 if !declared || got.Desktop.ProviderAccess == nil || len(got.Desktop.ProviderAccess) != 0 {
651 t.Fatalf("external explicit empty access was overwritten: declared=%v access=%#v", declared, got.Desktop.ProviderAccess)
652 }
653 }
654
655 func TestProviderSetupOperationReplayMaterializesLatestProjectProviders(t *testing.T) {
656 isolateUserConfig(t)
657 path := filepath.Join(t.TempDir(), "reasonix.toml")
658 initialBody := `
659 [[providers]]
660 name = "initial-project"
661 kind = "openai"
662 base_url = "https://initial.example/v1"
663 model = "initial-model"
664 api_key_env = ""
665 `
666 if err := os.WriteFile(path, []byte(initialBody), 0o644); err != nil {
667 t.Fatal(err)
668 }
669 working := config.LoadForEdit(path)
670 s := newProviderSetupSessionForPath(working, path)
671 added := config.ProviderEntry{Name: "setup-provider", Kind: "openai", BaseURL: "https://setup.example/v1", Model: "setup-model"}
672 if err := s.add([]config.ProviderEntry{added}); err != nil {
673 t.Fatal(err)
674 }
675 s.addProviderAccess([]config.ProviderEntry{added})
676
677 externalBody := initialBody + `
678 [[providers]]
679 name = "external-project"
680 kind = "openai"
681 base_url = "https://external.example/v1"
682 model = "external-model"
683 api_key_env = ""
684 `
685 if err := os.WriteFile(path, []byte(externalBody), 0o644); err != nil {
686 t.Fatal(err)
687 }
688 if _, err := commitProviderSetupSession(s, path); err != nil {
689 t.Fatalf("commitProviderSetupSession: %v", err)
690 }
691 got := config.LoadForEditWithoutCredentials(path)
692 for _, name := range []string{"initial-project", "external-project", "setup-provider"} {
693 if _, ok := got.Provider(name); !ok || !containsString(got.Desktop.ProviderAccess, name) {
694 t.Fatalf("latest project provider/access %q missing: providers=%v access=%v", name, got.Providers, got.Desktop.ProviderAccess)
695 }
696 }
697 for _, forbidden := range []string{"deepseek", "deepseek-flash", "deepseek-pro"} {
698 if containsString(got.Desktop.ProviderAccess, forbidden) {
699 t.Fatalf("materialized project access unexpectedly enabled %q: %v", forbidden, got.Desktop.ProviderAccess)
700 }
701 }
702 }
703
704 func TestProviderSetupCommitDoesNotOverwriteMalformedConcurrentConfig(t *testing.T) {
705 isolateUserConfig(t)
706 path := config.UserConfigPath()
707 initial := setupTestConfig()
708 if err := initial.SaveTo(path); err != nil {
709 t.Fatal(err)
710 }
711 working := config.LoadForEdit(path)
712 s := newProviderSetupSessionForPath(working, path)
713 s.setLanguage("en")
714 malformed := []byte("[[providers]\nname =")
715 if err := os.WriteFile(path, malformed, 0o600); err != nil {
716 t.Fatal(err)
717 }
718 if _, err := commitProviderSetupSession(s, path); err == nil {
719 t.Fatal("commit should reject malformed concurrent config")
720 }
721 after, err := os.ReadFile(path)
722 if err != nil {
723 t.Fatal(err)
724 }
725 if string(after) != string(malformed) {
726 t.Fatalf("malformed concurrent config was overwritten:\n%s", after)
727 }
728 }
729
730 func TestResolveSetupTargetsLocalKeepsGlobalCredentialTarget(t *testing.T) {
731 targets := resolveSetupTargets([]string{"--local"})
732 if targets.config != "reasonix.toml" {
733 t.Fatalf("local config target = %q", targets.config)
734 }
735 if targets.env != config.CredentialsTargetDescription() {
736 t.Fatalf("credential target = %q, want global %q", targets.env, config.CredentialsTargetDescription())
737 }
738 }
739
740 func TestLocalSetupPersistsWorkspaceProviderAccess(t *testing.T) {
741 cfg := setupTestConfig()
742 cfg.Desktop.ProviderAccess = []string{"grok-relay"}
743 path := filepath.Join(t.TempDir(), "reasonix.toml")
744 if err := cfg.SaveTo(path); err != nil {
745 t.Fatal(err)
746 }
747 body, err := os.ReadFile(path)
748 if err != nil {
749 t.Fatal(err)
750 }
751 text := string(body)
752 if !strings.Contains(text, "[desktop]") || !strings.Contains(text, `provider_access = ["grok-relay"]`) {
753 t.Fatalf("local setup omitted workspace desktop access:\n%s", text)
754 }
755 if strings.Contains(text, "theme_style") || strings.Contains(text, "default_tool_approval_mode") {
756 t.Fatalf("local setup leaked user-global desktop preferences:\n%s", text)
757 }
758 }
759
759 lines GO