返回 DeepSeek-Reasonix
migrate_test.go
根目录 / internal / config / migrate_test.go
1 package config
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10 )
11
12 // legacyHome points HOME / config-dir / .env resolution at a fresh temp tree and
13 // returns the legacy config.json path and the v1+ dest config path.
14 func legacyHome(t *testing.T) (src, dest, home string) {
15 t.Helper()
16 home = t.TempDir()
17 t.Setenv("HOME", home)
18 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
19 t.Setenv("USERPROFILE", home) // os.UserHomeDir on Windows
20 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) // os.UserConfigDir on Linux
21 t.Setenv("AppData", filepath.Join(home, "AppData")) // os.UserConfigDir on Windows
22 return filepath.Join(home, ".reasonix", "config.json"), userConfigPath(), home
23 }
24
25 func writeLegacy(t *testing.T, src, body string) {
26 t.Helper()
27 if err := os.MkdirAll(filepath.Dir(src), 0o755); err != nil {
28 t.Fatal(err)
29 }
30 if err := os.WriteFile(src, []byte(body), 0o644); err != nil {
31 t.Fatal(err)
32 }
33 }
34 func TestMigrateImportsKeyPluginsAndLang(t *testing.T) {
35 src, dest, home := legacyHome(t)
36 writeLegacy(t, src, `{
37 "apiKey": "sk-legacy-123",
38 "model": "deepseek-v4-pro",
39 "lang": "zh",
40 "mcpServers": {
41 "fs": {"command": "npx", "args": ["-y", "server-fs"], "type": "stdio"},
42 "stripe": {"type": "http", "url": "https://mcp.stripe.com", "disabled": true}
43 },
44 "mcpEnv": {"fs": {"ROOT": "/tmp"}}
45 }`)
46
47 res, err := MigrateLegacyIfNeeded()
48 if err != nil {
49 t.Fatalf("migrate: %v", err)
50 }
51 if res == nil {
52 t.Fatal("expected a migration result")
53 } else if !res.KeyToEnv || res.Plugins != 2 {
54 t.Errorf("result = %+v, want KeyToEnv=true Plugins=2", res)
55 }
56
57 envData, err := os.ReadFile(UserCredentialsPath())
58 if err != nil {
59 t.Fatalf("read credentials: %v", err)
60 }
61 if !strings.Contains(string(envData), "DEEPSEEK_API_KEY=sk-legacy-123") {
62 t.Errorf("credentials missing key: %q", envData)
63 }
64 if _, err := os.Stat(filepath.Join(home, ".env")); !os.IsNotExist(err) {
65 t.Errorf("migration must not write the user's ~/.env, stat err=%v", err)
66 }
67
68 got, err := os.ReadFile(dest)
69 if err != nil {
70 t.Fatalf("read dest config: %v", err)
71 }
72 toml := string(got)
73 for _, want := range []string{`language = "zh"`, `[desktop]`, `language = "zh"`, `name = "fs"`, `name = "stripe"`, `type = "http"`, `auto_start = false`} {
74 if !strings.Contains(toml, want) {
75 t.Errorf("dest config missing %q:\n%s", want, toml)
76 }
77 }
78 if !strings.Contains(toml, `default_model = "deepseek-pro/deepseek-v4-pro"`) {
79 t.Errorf("dest config missing imported model:\n%s", toml)
80 }
81
82 loaded, err := Load()
83 if err != nil {
84 t.Fatalf("load: %v", err)
85 }
86 if loaded.DefaultModel != "deepseek-pro/deepseek-v4-pro" {
87 t.Errorf("DefaultModel = %q, want deepseek-pro/deepseek-v4-pro", loaded.DefaultModel)
88 }
89
90 if _, err := os.Stat(src); err != nil {
91 t.Errorf("legacy file must be left untouched: %v", err)
92 }
93 }
94
95 func TestMigrateImportsLegacyQQConfig(t *testing.T) {
96 src, dest, _ := legacyHome(t)
97 writeLegacy(t, src, `{
98 "qq": {
99 "enabled": true,
100 "appId": "qq-app-id",
101 "appSecret": "qq-secret",
102 "sandbox": true,
103 "ownerOpenId": " owner-openid ",
104 "allowlist": ["owner-openid", " member-openid ", "member-openid", ""]
105 }
106 }`)
107
108 res, err := MigrateLegacyIfNeeded()
109 if err != nil {
110 t.Fatalf("migrate: %v", err)
111 }
112 if res == nil {
113 t.Fatal("expected migration result")
114 }
115 envData, err := os.ReadFile(UserCredentialsPath())
116 if err != nil {
117 t.Fatalf("read credentials: %v", err)
118 }
119 if !strings.Contains(string(envData), "QQ_BOT_APP_SECRET=qq-secret") {
120 t.Fatalf("credentials missing QQ secret: %q", envData)
121 }
122 tomlData, err := os.ReadFile(dest)
123 if err != nil {
124 t.Fatalf("read dest: %v", err)
125 }
126 toml := string(tomlData)
127 for _, want := range []string{
128 `[bot.qq]`,
129 `enabled = true`,
130 `app_id = "qq-app-id"`,
131 `app_secret_env = "QQ_BOT_APP_SECRET"`,
132 `sandbox = true`,
133 `qq_users = ["owner-openid", "member-openid"]`,
134 } {
135 if !strings.Contains(toml, want) {
136 t.Fatalf("migrated config missing %q:\n%s", want, toml)
137 }
138 }
139 if strings.Contains(toml, "qq-secret") {
140 t.Fatalf("migrated TOML must not contain QQ secret:\n%s", toml)
141 }
142 }
143
144 // TestMigrateImportsLegacyMCPStringList covers the pre-mcpServers `mcp` format
145 // (#3949): `--mcp`-style strings, with mcpEnv/mcpDisabled keyed by name and
146 // mcpServers winning a name collision.
147 func TestMigrateImportsLegacyMCPStringList(t *testing.T) {
148 src, _, _ := legacyHome(t)
149 writeLegacy(t, src, `{
150 "mcp": [
151 "memory=npx -y @modelcontextprotocol/server-memory",
152 "search=https://mcp.example.com/sse",
153 "stream=streamable+https://mcp.example.com/http",
154 "fs=node old-fs.js",
155 "off=npx -y server-off"
156 ],
157 "mcpServers": {"fs": {"command": "npx", "args": ["-y", "server-fs"]}},
158 "mcpEnv": {"memory": {"MEMORY_PATH": "/tmp/mem"}},
159 "mcpDisabled": ["off"]
160 }`)
161
162 if _, err := MigrateLegacyIfNeeded(); err != nil {
163 t.Fatalf("migrate: %v", err)
164 }
165 cfg, err := Load()
166 if err != nil {
167 t.Fatalf("load: %v", err)
168 }
169 byName := map[string]PluginEntry{}
170 for _, p := range cfg.Plugins {
171 byName[p.Name] = p
172 }
173 mem := byName["memory"]
174 if mem.Command != "npx" || len(mem.Args) != 2 || mem.Args[1] != "@modelcontextprotocol/server-memory" {
175 t.Errorf("memory spec not parsed: %+v", mem)
176 }
177 if mem.Env["MEMORY_PATH"] != "/tmp/mem" {
178 t.Errorf("mcpEnv not applied to memory: %+v", mem.Env)
179 }
180 if s := byName["search"]; s.Type != "sse" || s.URL != "https://mcp.example.com/sse" {
181 t.Errorf("plain URL should migrate as SSE: %+v", s)
182 }
183 if s := byName["stream"]; s.Type != "http" || s.URL != "https://mcp.example.com/http" {
184 t.Errorf("streamable+ URL should migrate as http: %+v", s)
185 }
186 if fs := byName["fs"]; len(fs.Args) != 2 || fs.Args[1] != "server-fs" {
187 t.Errorf("mcpServers should win the fs name collision: %+v", fs)
188 }
189 if off := byName["off"]; off.AutoStart == nil || *off.AutoStart {
190 t.Errorf("mcpDisabled entry should migrate with auto_start=false: %+v", off)
191 }
192 if len(cfg.Plugins) != 5 {
193 t.Errorf("got %d plugins, want 5: %+v", len(cfg.Plugins), cfg.Plugins)
194 }
195 }
196
197 func TestMigrateRoundTripsThroughLoad(t *testing.T) {
198 src, _, _ := legacyHome(t)
199 writeLegacy(t, src, `{"apiKey":"sk-x","mcpServers":{"fs":{"command":"npx","env":{"A":"1"}}}}`)
200
201 if _, err := MigrateLegacyIfNeeded(); err != nil {
202 t.Fatalf("migrate: %v", err)
203 }
204 cfg, err := Load()
205 if err != nil {
206 t.Fatalf("load: %v", err)
207 }
208 if len(cfg.Plugins) != 1 || cfg.Plugins[0].Name != "fs" || cfg.Plugins[0].Command != "npx" {
209 t.Errorf("plugins did not round-trip through Load: %+v", cfg.Plugins)
210 }
211 if cfg.Plugins[0].Env["A"] != "1" {
212 t.Errorf("plugin env lost: %+v", cfg.Plugins[0].Env)
213 }
214 }
215
216 func TestMigrateSkipsWhenDestExists(t *testing.T) {
217 src, dest, _ := legacyHome(t)
218 writeLegacy(t, src, `{"apiKey":"sk-x"}`)
219 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
220 t.Fatal(err)
221 }
222 if err := os.WriteFile(dest, []byte("default_model = \"deepseek-flash\"\n"), 0o644); err != nil {
223 t.Fatal(err)
224 }
225
226 res, err := MigrateLegacyIfNeeded()
227 if err != nil {
228 t.Fatalf("migrate: %v", err)
229 }
230 if res != nil {
231 t.Errorf("must not migrate over an existing v1+ config, got %+v", res)
232 }
233 }
234
235 func TestMigrateMCPToUserConfigOnUpgradeCollectsKnownSources(t *testing.T) {
236 srcJSON, dest, _ := legacyHome(t)
237 writeLegacy(t, srcJSON, `{
238 "mcpServers": {
239 "legacy-json": {"command": "legacy-json-bin"},
240 "disabled-json": {"command": "disabled-json-bin", "disabled": true},
241 "project-json": {"command": "legacy-json-should-not-win"},
242 "global": {"command": "legacy-should-not-win"}
243 }
244 }`)
245 writeLegacy(t, filepath.Join(filepath.Dir(dest), "reasonix.toml"), `
246 [[plugins]]
247 name = "legacy-toml"
248 command = "legacy-toml-bin"
249 `)
250 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
251 t.Fatal(err)
252 }
253 if err := os.WriteFile(dest, []byte(`
254 [[plugins]]
255 name = "global"
256 command = "global-bin"
257 `), 0o644); err != nil {
258 t.Fatal(err)
259 }
260 projectTOML := t.TempDir()
261 if err := os.WriteFile(filepath.Join(projectTOML, "reasonix.toml"), []byte(`
262 [[plugins]]
263 name = "project-toml"
264 command = "project-toml-bin"
265
266 [[plugins]]
267 name = "global"
268 command = "project-should-not-win"
269 `), 0o644); err != nil {
270 t.Fatal(err)
271 }
272 projectJSON := t.TempDir()
273 if err := os.WriteFile(filepath.Join(projectJSON, ".mcp.json"), []byte(`{
274 "mcpServers": {
275 "project-json": {"command": "project-json-bin"}
276 }
277 }`), 0o644); err != nil {
278 t.Fatal(err)
279 }
280
281 res, err := MigrateMCPToUserConfigOnUpgrade([]string{projectTOML, projectJSON, projectTOML})
282 if err != nil {
283 t.Fatalf("MigrateMCPToUserConfigOnUpgrade: %v", err)
284 }
285 if res == nil || res.Added != 5 {
286 t.Fatalf("migration result = %+v, want 5 added", res)
287 }
288 cfg := LoadForEdit(dest)
289 byName := map[string]PluginEntry{}
290 for _, p := range cfg.Plugins {
291 byName[p.Name] = p
292 }
293 for name, command := range map[string]string{
294 "global": "global-bin",
295 "disabled-json": "disabled-json-bin",
296 "legacy-toml": "legacy-toml-bin",
297 "legacy-json": "legacy-json-bin",
298 "project-toml": "project-toml-bin",
299 "project-json": "project-json-bin",
300 } {
301 if byName[name].Command != command {
302 t.Fatalf("%s command = %q, want %q; plugins=%+v", name, byName[name].Command, command, cfg.Plugins)
303 }
304 }
305 if p := byName["disabled-json"]; p.AutoStart == nil || *p.AutoStart {
306 t.Fatalf("disabled legacy MCP should migrate with auto_start=false: %+v", p)
307 }
308 if _, err := os.Stat(mcpGlobalMigrationMarkerPath()); err != nil {
309 t.Fatalf("migration marker missing: %v", err)
310 }
311
312 lateProject := t.TempDir()
313 if err := os.WriteFile(filepath.Join(lateProject, "reasonix.toml"), []byte(`
314 [[plugins]]
315 name = "late"
316 command = "late-bin"
317 `), 0o644); err != nil {
318 t.Fatal(err)
319 }
320 res, err = MigrateMCPToUserConfigOnUpgrade([]string{lateProject})
321 if err != nil {
322 t.Fatalf("second migration: %v", err)
323 }
324 if res != nil {
325 t.Fatalf("second migration result = %+v, want nil due marker", res)
326 }
327 if got := LoadForEdit(dest); len(got.Plugins) != len(cfg.Plugins) {
328 t.Fatalf("second migration changed plugins: %+v", got.Plugins)
329 }
330 }
331
332 func TestMCPMigrationMarkerMakesCurrentConfigAuthoritativeAfterRemoval(t *testing.T) {
333 src, dest, _ := legacyHome(t)
334 writeLegacy(t, src, `{
335 "mcpServers": {
336 "legacy-only": {"command": "legacy-mcp-bin"}
337 }
338 }`)
339 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
340 t.Fatal(err)
341 }
342 if err := os.WriteFile(dest, []byte("config_version = 1\n"), 0o644); err != nil {
343 t.Fatal(err)
344 }
345
346 res, err := MigrateMCPToUserConfigOnUpgrade(nil)
347 if err != nil {
348 t.Fatalf("MigrateMCPToUserConfigOnUpgrade: %v", err)
349 }
350 if res == nil || res.Added != 1 {
351 t.Fatalf("migration result = %+v, want one imported MCP", res)
352 }
353
354 removed, err := RemovePluginFromSourcesForRoot(t.TempDir(), "legacy-only")
355 if err != nil {
356 t.Fatalf("RemovePluginFromSourcesForRoot: %v", err)
357 }
358 if !removed {
359 t.Fatal("RemovePluginFromSourcesForRoot reported no removal")
360 }
361
362 loaded, err := Load()
363 if err != nil {
364 t.Fatalf("Load after removal: %v", err)
365 }
366 for _, p := range loaded.Plugins {
367 if p.Name == "legacy-only" {
368 t.Fatalf("removed MCP was resurrected from legacy config: %+v", loaded.Plugins)
369 }
370 }
371 if got := loadLegacyMCP(src); len(got) != 0 {
372 t.Fatalf("an older runtime would resurrect the removed legacy MCP: %+v", got)
373 }
374 legacyRaw, err := os.ReadFile(src)
375 if err != nil {
376 t.Fatalf("read legacy source: %v", err)
377 }
378 if !strings.Contains(string(legacyRaw), `"legacy-only"`) || !strings.Contains(string(legacyRaw), `"mcpDisabled"`) {
379 t.Fatalf("legacy source should retain the server and add a compatibility disable marker:\n%s", legacyRaw)
380 }
381 }
382
383 func TestMigrateMCPToUserConfigOnUpgradeDoesNotMarkEmptyScan(t *testing.T) {
384 _, _, _ = legacyHome(t)
385 res, err := MigrateMCPToUserConfigOnUpgrade(nil)
386 if err != nil {
387 t.Fatalf("MigrateMCPToUserConfigOnUpgrade: %v", err)
388 }
389 if res != nil {
390 t.Fatalf("result = %+v, want nil", res)
391 }
392 if _, err := os.Stat(mcpGlobalMigrationMarkerPath()); !os.IsNotExist(err) {
393 t.Fatalf("empty scan should not write marker, stat err=%v", err)
394 }
395 }
396
397 func TestMigrateMCPToUserConfigOnUpgradeRefusesMalformedGlobalConfig(t *testing.T) {
398 srcJSON, dest, _ := legacyHome(t)
399 writeLegacy(t, srcJSON, `{
400 "mcpServers": {
401 "legacy-json": {"command": "legacy-json-bin"}
402 }
403 }`)
404 const malformed = "[[plugins]\nname = \"broken\"\n"
405 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
406 t.Fatal(err)
407 }
408 if err := os.WriteFile(dest, []byte(malformed), 0o644); err != nil {
409 t.Fatal(err)
410 }
411
412 res, err := MigrateMCPToUserConfigOnUpgrade(nil)
413 if err == nil {
414 t.Fatal("expected malformed global config to abort MCP migration")
415 }
416 if res != nil {
417 t.Fatalf("result = %+v, want nil", res)
418 }
419 if got, readErr := os.ReadFile(dest); readErr != nil {
420 t.Fatalf("read dest: %v", readErr)
421 } else if string(got) != malformed {
422 t.Fatalf("malformed config was overwritten:\n%s", got)
423 }
424 if _, statErr := os.Stat(mcpGlobalMigrationMarkerPath()); !os.IsNotExist(statErr) {
425 t.Fatalf("failed migration must not write marker, stat err=%v", statErr)
426 }
427 }
428
429 func TestMigrateMCPToUserConfigOnUpgradePreservesConfigVersion(t *testing.T) {
430 _, dest, _ := legacyHome(t)
431 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
432 t.Fatal(err)
433 }
434 if err := os.WriteFile(dest, []byte("config_version = 1\n"), 0o644); err != nil {
435 t.Fatal(err)
436 }
437 project := t.TempDir()
438 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
439 [[plugins]]
440 name = "project"
441 command = "project-bin"
442 `), 0o644); err != nil {
443 t.Fatal(err)
444 }
445
446 res, err := MigrateMCPToUserConfigOnUpgrade([]string{project})
447 if err != nil {
448 t.Fatalf("MigrateMCPToUserConfigOnUpgrade: %v", err)
449 }
450 if res == nil || res.Added != 1 {
451 t.Fatalf("result = %+v, want 1 added", res)
452 }
453 got, err := os.ReadFile(dest)
454 if err != nil {
455 t.Fatalf("read dest: %v", err)
456 }
457 if !strings.Contains(string(got), "config_version = 1") {
458 t.Fatalf("MCP migration should not advance config_version:\n%s", got)
459 }
460 }
461
462 func TestMigrateImportsLegacyV1TOMLBeforeJSON(t *testing.T) {
463 srcJSON, dest, _ := legacyHome(t)
464 legacyTOML := filepath.Join(filepath.Dir(dest), "reasonix.toml")
465 if err := os.MkdirAll(filepath.Dir(legacyTOML), 0o755); err != nil {
466 t.Fatal(err)
467 }
468 if err := os.WriteFile(legacyTOML, []byte(`
469 default_model = "deepseek-flash"
470 language = "en"
471
472 [ui]
473 theme = "light"
474 theme_style = "glacier"
475 close_behavior = "quit"
476
477 [[plugins]]
478 name = "legacy-v1"
479 command = "legacy-bin"
480 `), 0o644); err != nil {
481 t.Fatal(err)
482 }
483 writeLegacy(t, srcJSON, `{"apiKey":"sk-json-should-not-win"}`)
484
485 res, err := MigrateLegacyIfNeeded()
486 if err != nil {
487 t.Fatalf("migrate: %v", err)
488 }
489 if res == nil || res.From != legacyTOML {
490 t.Fatalf("expected v1 TOML migration, got %+v", res)
491 }
492
493 got, err := os.ReadFile(dest)
494 if err != nil {
495 t.Fatalf("read migrated config: %v", err)
496 }
497 text := string(got)
498 for _, want := range []string{`config_version = 5`, `[desktop]`, `close_behavior = "quit"`, `name = "legacy-v1"`} {
499 if !strings.Contains(text, want) {
500 t.Fatalf("migrated TOML missing %q:\n%s", want, text)
501 }
502 }
503 if _, err := os.Stat(UserCredentialsPath()); !os.IsNotExist(err) {
504 t.Fatalf("v1 TOML migration should not import lower-priority JSON key, credentials stat err=%v", err)
505 }
506 }
507
508 func TestMigrateImportsLegacyV1HomeTOMLBeforeJSON(t *testing.T) {
509 srcJSON, dest, home := legacyHome(t)
510 legacyTOML := filepath.Join(home, ".reasonix", "reasonix.toml")
511 if err := os.MkdirAll(filepath.Dir(legacyTOML), 0o755); err != nil {
512 t.Fatal(err)
513 }
514 if err := os.WriteFile(legacyTOML, []byte(`
515 default_model = "deepseek-flash"
516
517 [[plugins]]
518 name = "legacy-home-v1"
519 command = "legacy-home-bin"
520 `), 0o644); err != nil {
521 t.Fatal(err)
522 }
523 writeLegacy(t, srcJSON, `{"apiKey":"sk-json-should-not-win","mcpServers":{"json":{"command":"json-bin"}}}`)
524
525 res, err := MigrateLegacyIfNeeded()
526 if err != nil {
527 t.Fatalf("migrate: %v", err)
528 }
529 if res == nil || res.From != legacyTOML {
530 t.Fatalf("expected home v1 TOML migration, got %+v", res)
531 }
532
533 got, err := os.ReadFile(dest)
534 if err != nil {
535 t.Fatalf("read migrated config: %v", err)
536 }
537 text := string(got)
538 if !strings.Contains(text, `name = "legacy-home-v1"`) {
539 t.Fatalf("home v1 plugin was not migrated:\n%s", text)
540 }
541 if strings.Contains(text, `name = "json"`) {
542 t.Fatalf("lower-priority v0.5 JSON should not be merged when v1 TOML exists:\n%s", text)
543 }
544 }
545
546 func TestLoadFallsBackToLegacyOSConfigWhenPrimaryMissing(t *testing.T) {
547 _, dest, _ := legacyHome(t)
548 legacy := legacyUserConfigPath()
549 if legacy == "" {
550 t.Skip("legacy OS config path matches primary path on this platform")
551 }
552 if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
553 t.Fatal(err)
554 }
555 if err := os.WriteFile(legacy, []byte(`default_model = "legacy-provider/legacy-model"`), 0o644); err != nil {
556 t.Fatal(err)
557 }
558
559 if source := SourcePath(); source != legacy {
560 t.Fatalf("SourcePath() = %q, want legacy path %q", source, legacy)
561 }
562 cfg, err := Load()
563 if err != nil {
564 t.Fatalf("Load: %v", err)
565 }
566 if cfg.DefaultModel != "legacy-provider/legacy-model" {
567 t.Fatalf("DefaultModel = %q, want legacy value", cfg.DefaultModel)
568 }
569 if _, err := os.Stat(dest); !os.IsNotExist(err) {
570 t.Fatalf("Load fallback should not create primary config, stat err=%v", err)
571 }
572 }
573
574 func TestLoadPrefersPrimaryConfigOverLegacyOSConfig(t *testing.T) {
575 _, dest, _ := legacyHome(t)
576 legacy := legacyUserConfigPath()
577 if legacy == "" {
578 t.Skip("legacy OS config path matches primary path on this platform")
579 }
580 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
581 t.Fatal(err)
582 }
583 if err := os.WriteFile(dest, []byte(`default_model = "primary-provider/primary-model"`), 0o644); err != nil {
584 t.Fatal(err)
585 }
586 if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
587 t.Fatal(err)
588 }
589 if err := os.WriteFile(legacy, []byte(`default_model = "legacy-provider/legacy-model"`), 0o644); err != nil {
590 t.Fatal(err)
591 }
592
593 if source := SourcePath(); source != dest {
594 t.Fatalf("SourcePath() = %q, want primary path %q", source, dest)
595 }
596 cfg, err := Load()
597 if err != nil {
598 t.Fatalf("Load: %v", err)
599 }
600 if cfg.DefaultModel != "primary-provider/primary-model" {
601 t.Fatalf("DefaultModel = %q, want primary value", cfg.DefaultModel)
602 }
603 }
604
605 func TestMigrateImportsLegacyOSConfigToPrimaryConfig(t *testing.T) {
606 _, dest, _ := legacyHome(t)
607 legacy := legacyUserConfigPath()
608 if legacy == "" {
609 t.Skip("legacy OS config path matches primary path on this platform")
610 }
611 if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
612 t.Fatal(err)
613 }
614 if err := os.WriteFile(legacy, []byte(`
615 default_model = "legacy-provider/legacy-model"
616
617 [[plugins]]
618 name = "legacy-os"
619 command = "legacy-os-bin"
620 `), 0o644); err != nil {
621 t.Fatal(err)
622 }
623
624 res, err := MigrateLegacyIfNeeded()
625 if err != nil {
626 t.Fatalf("migrate: %v", err)
627 }
628 if res == nil || res.From != legacy || res.To != dest {
629 t.Fatalf("migration result = %+v, want %s -> %s", res, legacy, dest)
630 }
631 got, err := os.ReadFile(dest)
632 if err != nil {
633 t.Fatalf("read migrated config: %v", err)
634 }
635 if !strings.Contains(string(got), `name = "legacy-os"`) {
636 t.Fatalf("dest missing legacy plugin:\n%s", got)
637 }
638 if _, err := os.Stat(legacy); err != nil {
639 t.Fatalf("legacy file must remain untouched: %v", err)
640 }
641 }
642
643 func TestMigrateImportsLegacyXDGConfigToPrimaryConfig(t *testing.T) {
644 if runtime.GOOS == "windows" {
645 t.Skip("legacy XDG paths are Unix-only")
646 }
647 _, dest, home := legacyHome(t)
648 legacy := filepath.Join(home, ".config", "reasonix", "config.toml")
649 if samePath(legacy, dest) {
650 t.Skip("legacy XDG config path matches primary path on this platform")
651 }
652 if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
653 t.Fatal(err)
654 }
655 if err := os.WriteFile(legacy, []byte(`
656 default_model = "legacy-xdg-provider/legacy-xdg-model"
657
658 [[plugins]]
659 name = "legacy-xdg"
660 command = "legacy-xdg-bin"
661 `), 0o644); err != nil {
662 t.Fatal(err)
663 }
664
665 res, err := MigrateLegacyIfNeeded()
666 if err != nil {
667 t.Fatalf("migrate: %v", err)
668 }
669 if res == nil || res.From != legacy || res.To != dest {
670 t.Fatalf("migration result = %+v, want %s -> %s", res, legacy, dest)
671 }
672 got, err := os.ReadFile(dest)
673 if err != nil {
674 t.Fatalf("read migrated config: %v", err)
675 }
676 if !strings.Contains(string(got), `name = "legacy-xdg"`) {
677 t.Fatalf("dest missing legacy XDG plugin:\n%s", got)
678 }
679 }
680
681 func TestMigrateImportsLegacyCredentialsEvenWhenPrimaryConfigExists(t *testing.T) {
682 _, dest, _ := legacyHome(t)
683 legacy := legacyUserConfigPath()
684 if legacy == "" {
685 t.Skip("legacy OS config path matches primary path on this platform")
686 }
687 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
688 t.Fatal(err)
689 }
690 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
691 t.Fatal(err)
692 }
693 legacyCred := filepath.Join(filepath.Dir(legacy), "credentials")
694 if err := os.MkdirAll(filepath.Dir(legacyCred), 0o755); err != nil {
695 t.Fatal(err)
696 }
697 if err := os.WriteFile(legacyCred, []byte("DEEPSEEK_API_KEY=sk-old-creds\n"), 0o600); err != nil {
698 t.Fatal(err)
699 }
700
701 res, err := MigrateLegacyIfNeeded()
702 if err != nil {
703 t.Fatalf("migrate: %v", err)
704 }
705 if res != nil {
706 t.Fatalf("primary config exists, config migration should be skipped, got %+v", res)
707 }
708 data, err := os.ReadFile(UserCredentialsPath())
709 if err != nil {
710 t.Fatalf("read migrated credentials: %v", err)
711 }
712 if string(data) != "DEEPSEEK_API_KEY=sk-old-creds\n" {
713 t.Fatalf("migrated credentials = %q", data)
714 }
715 }
716
717 func TestMigrateImportsLegacyKeyringCredentials(t *testing.T) {
718 legacyHome(t)
719 old := legacyKeyringProbeLookup
720 legacyKeyringProbeLookup = func(_ context.Context, key string) legacyKeyringOutcome {
721 if key == "DEEPSEEK_API_KEY" {
722 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: "sk-old-keyring"}
723 }
724 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
725 }
726 t.Cleanup(func() { legacyKeyringProbeLookup = old })
727
728 res, err := MigrateLegacyIfNeeded()
729 if err != nil {
730 t.Fatalf("migrate: %v", err)
731 }
732 if res != nil {
733 t.Fatalf("no config migration should be needed, got %+v", res)
734 }
735 data, err := os.ReadFile(UserCredentialsPath())
736 if err != nil {
737 t.Fatalf("read migrated credentials: %v", err)
738 }
739 if string(data) != "DEEPSEEK_API_KEY=sk-old-keyring\n" {
740 t.Fatalf("migrated credentials = %q", data)
741 }
742 }
743
744 func TestMigrateLegacyCredentialsUsesWorkspaceRootForKeyring(t *testing.T) {
745 _, dest, _ := legacyHome(t)
746 project := t.TempDir()
747 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
748 t.Fatal(err)
749 }
750 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
751 t.Fatal(err)
752 }
753 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
754 default_model = "custom/m"
755 [[providers]]
756 name = "custom"
757 kind = "openai"
758 base_url = "https://example.invalid/v1"
759 model = "m"
760 api_key_env = "WORKSPACE_ONLY_KEY"
761 `), 0o644); err != nil {
762 t.Fatal(err)
763 }
764 old := legacyKeyringProbeLookup
765 legacyKeyringProbeLookup = func(_ context.Context, key string) legacyKeyringOutcome {
766 if key == "WORKSPACE_ONLY_KEY" {
767 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: "sk-workspace"}
768 }
769 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
770 }
771 t.Cleanup(func() { legacyKeyringProbeLookup = old })
772
773 if err := MigrateLegacyCredentialsForRoot(project); err != nil {
774 t.Fatalf("MigrateLegacyCredentialsForRoot: %v", err)
775 }
776 data, err := os.ReadFile(UserCredentialsPath())
777 if err != nil {
778 t.Fatalf("read migrated credentials: %v", err)
779 }
780 if string(data) != "WORKSPACE_ONLY_KEY=sk-workspace\n" {
781 t.Fatalf("migrated credentials = %q", data)
782 }
783 }
784
785 func TestMigrateLegacyCredentialsSkipsKeyringWhenIsolated(t *testing.T) {
786 home := t.TempDir()
787 isolated := filepath.Join(home, "isolated-home")
788 t.Setenv("HOME", home)
789 t.Setenv("USERPROFILE", home)
790 t.Setenv("AppData", filepath.Join(home, "AppData"))
791 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
792 t.Setenv("REASONIX_HOME", isolated)
793 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
794
795 old := legacyKeyringProbeLookup
796 legacyKeyringProbeLookup = func(_ context.Context, key string) legacyKeyringOutcome {
797 if key == "DEEPSEEK_API_KEY" {
798 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: "legacy-keyring-value"}
799 }
800 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
801 }
802 t.Cleanup(func() { legacyKeyringProbeLookup = old })
803
804 if err := MigrateLegacyCredentialsForRoot("."); err != nil {
805 t.Fatalf("MigrateLegacyCredentialsForRoot: %v", err)
806 }
807 if _, err := os.Stat(UserCredentialsPath()); !os.IsNotExist(err) {
808 t.Fatalf("isolated runtime imported legacy credentials to %s; stat err=%v", UserCredentialsPath(), err)
809 }
810 }
811
812 func TestMigrateLegacyCredentialsDoesNotReimportClearedKey(t *testing.T) {
813 _, dest, _ := legacyHome(t)
814 legacy := legacyUserConfigPath()
815 if legacy == "" {
816 t.Skip("legacy OS config path matches primary path on this platform")
817 }
818 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
819 t.Fatal(err)
820 }
821 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
822 t.Fatal(err)
823 }
824 legacyCred := filepath.Join(filepath.Dir(legacy), "credentials")
825 if err := os.MkdirAll(filepath.Dir(legacyCred), 0o755); err != nil {
826 t.Fatal(err)
827 }
828 if err := os.WriteFile(legacyCred, []byte("DEEPSEEK_API_KEY=sk-old-creds\n"), 0o600); err != nil {
829 t.Fatal(err)
830 }
831
832 if err := MigrateLegacyCredentialsForRoot("."); err != nil {
833 t.Fatalf("initial migrate: %v", err)
834 }
835 if err := RemoveCredential("DEEPSEEK_API_KEY"); err != nil {
836 t.Fatalf("RemoveCredential: %v", err)
837 }
838 if err := MigrateLegacyCredentialsForRoot("."); err != nil {
839 t.Fatalf("second migrate: %v", err)
840 }
841 data, err := os.ReadFile(UserCredentialsPath())
842 if err != nil {
843 t.Fatalf("read current credentials: %v", err)
844 }
845 if strings.Contains(string(data), "sk-old-creds") || CredentialStored("DEEPSEEK_API_KEY") {
846 t.Fatalf("cleared key was re-imported:\n%s", data)
847 }
848 if !strings.Contains(string(data), credentialClearedPrefix+"DEEPSEEK_API_KEY") {
849 t.Fatalf("cleared marker missing:\n%s", data)
850 }
851 }
852
853 func TestMigrateSkipsLegacyCredentialsAlreadyInCurrentAutoStore(t *testing.T) {
854 _, dest, _ := legacyHome(t)
855 t.Setenv("REASONIX_CREDENTIALS_STORE", "")
856 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
857 t.Fatal(err)
858 }
859 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
860 t.Fatal(err)
861 }
862 currentCred := UserCredentialsPath()
863 if err := os.MkdirAll(filepath.Dir(currentCred), 0o755); err != nil {
864 t.Fatal(err)
865 }
866 if err := os.WriteFile(currentCred, []byte("DEEPSEEK_API_KEY=sk-current\n"), 0o600); err != nil {
867 t.Fatal(err)
868 }
869
870 legacyPaths := legacyCredentialsPaths()
871 if len(legacyPaths) == 0 {
872 t.Skip("no legacy credentials path on this platform")
873 }
874 legacyCred := legacyPaths[0]
875 if err := os.MkdirAll(filepath.Dir(legacyCred), 0o755); err != nil {
876 t.Fatal(err)
877 }
878 if err := os.WriteFile(legacyCred, []byte("DEEPSEEK_API_KEY=sk-stale\n"), 0o600); err != nil {
879 t.Fatal(err)
880 }
881
882 res, err := MigrateLegacyIfNeeded()
883 if err != nil {
884 t.Fatalf("migrate: %v", err)
885 }
886 if res != nil {
887 t.Fatalf("primary config exists, config migration should be skipped, got %+v", res)
888 }
889 data, err := os.ReadFile(currentCred)
890 if err != nil {
891 t.Fatalf("read current credentials: %v", err)
892 }
893 if string(data) != "DEEPSEEK_API_KEY=sk-current\n" {
894 t.Fatalf("current credentials were overwritten: %q", data)
895 }
896 }
897
898 func TestMigrateImportsLegacyStateHomeDotEnvCredentials(t *testing.T) {
899 _, dest, _ := legacyHome(t)
900 state := t.TempDir()
901 t.Setenv("REASONIX_STATE_HOME", state)
902 t.Setenv("REASONIX_CREDENTIALS_STORE", "")
903 os.Unsetenv("REASONIX_CREDENTIALS_STORE")
904 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
905 t.Fatal(err)
906 }
907 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
908 t.Fatal(err)
909 }
910 if err := os.WriteFile(filepath.Join(state, ".env"), []byte("DEEPSEEK_API_KEY=state-env-value\n"), 0o600); err != nil {
911 t.Fatal(err)
912 }
913
914 currentCred := UserCredentialsPath()
915 if strings.HasPrefix(currentCred, state) {
916 t.Fatalf("current credentials path should not be under REASONIX_STATE_HOME: %q", currentCred)
917 }
918 res, err := MigrateLegacyIfNeeded()
919 if err != nil {
920 t.Fatalf("migrate: %v", err)
921 }
922 if res != nil {
923 t.Fatalf("primary config exists, config migration should be skipped, got %+v", res)
924 }
925 data, err := os.ReadFile(currentCred)
926 if err != nil {
927 t.Fatalf("read current credentials: %v", err)
928 }
929 if string(data) != "DEEPSEEK_API_KEY=state-env-value\n" {
930 t.Fatalf("migrated credentials = %q", data)
931 }
932 }
933
934 func TestMigrateNoLegacyIsNoop(t *testing.T) {
935 legacyHome(t)
936 res, err := MigrateLegacyIfNeeded()
937 if err != nil || res != nil {
938 t.Errorf("no legacy install should be a silent no-op, got res=%+v err=%v", res, err)
939 }
940 }
941
942 func TestMigrateToleratesUTF8BOM(t *testing.T) {
943 src, _, _ := legacyHome(t)
944 writeLegacy(t, src, "\ufeff"+`{"apiKey":"sk-bom"}`)
945 res, err := MigrateLegacyIfNeeded()
946 if err != nil {
947 t.Fatalf("a BOM-prefixed legacy config must still parse: %v", err)
948 }
949 if res == nil || !res.KeyToEnv {
950 t.Fatalf("BOM-prefixed config did not migrate: %+v", res)
951 }
952 data, _ := os.ReadFile(UserCredentialsPath())
953 if !strings.Contains(string(data), "DEEPSEEK_API_KEY=sk-bom") {
954 t.Errorf("key not migrated from BOM-prefixed config: %q", data)
955 }
956 }
957
958 func TestMigrateCustomBaseURLWarns(t *testing.T) {
959 src, _, _ := legacyHome(t)
960 writeLegacy(t, src, `{"apiKey":"sk-x","baseUrl":"https://my-proxy.example/v1"}`)
961 res, err := MigrateLegacyIfNeeded()
962 if err != nil {
963 t.Fatalf("migrate: %v", err)
964 }
965 if len(res.Warnings) == 0 {
966 t.Error("a non-DeepSeek base_url should produce a warning")
967 }
968 cfg, err := Load()
969 if err != nil {
970 t.Fatalf("load migrated config: %v", err)
971 }
972 for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
973 p, ok := cfg.Provider(name)
974 if !ok || p.BaseURL != "https://my-proxy.example/v1" {
975 t.Fatalf("%s base_url was not migrated: %+v", name, p)
976 }
977 }
978 }
979
980 func TestMigrateSupportData(t *testing.T) {
981 if runtime.GOOS == "windows" {
982 t.Skip("skipping since legacyOSSupportDir equals current reasonixHomeDir on Windows")
983 }
984 home := t.TempDir()
985 t.Setenv("HOME", home)
986 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
987 t.Setenv("USERPROFILE", home)
988 t.Setenv("AppData", filepath.Join(home, "AppData"))
989
990 legacyConf := legacyUserConfigPath()
991 if legacyConf == "" {
992 t.Skip("skipping because legacy config path is empty")
993 }
994 legacyDir := filepath.Dir(legacyConf)
995
996 // Write data to the legacy support directory
997 filesToWrite := map[string]string{
998 "config.toml": "language = \"zh\"",
999 "hooks.json": `{"hook":"test"}`,
1000 "sessions/s1.json": `{"id":"s1"}`,
1001 "projects/p1/sessions/s2.json": `{"id":"s2"}`,
1002 "skills/custom.md": `custom skill`,
1003 "archive/a1.json": `{"compacted": true}`,
1004 }
1005 for rel, content := range filesToWrite {
1006 path := filepath.Join(legacyDir, rel)
1007 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
1008 t.Fatal(err)
1009 }
1010 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
1011 t.Fatal(err)
1012 }
1013 }
1014 if runtime.GOOS != "windows" {
1015 if err := os.Chmod(filepath.Join(legacyDir, "sessions"), 0o700); err != nil {
1016 t.Fatal(err)
1017 }
1018 if err := os.Chmod(filepath.Join(legacyDir, "sessions", "s1.json"), 0o600); err != nil {
1019 t.Fatal(err)
1020 }
1021 if err := os.Chmod(filepath.Join(legacyDir, "hooks.json"), 0o600); err != nil {
1022 t.Fatal(err)
1023 }
1024 }
1025
1026 res, err := MigrateLegacyIfNeeded()
1027 if err != nil {
1028 t.Fatalf("MigrateLegacyIfNeeded failed: %v", err)
1029 }
1030 if res == nil {
1031 t.Fatal("expected migration result, got nil")
1032 }
1033
1034 newDir := filepath.Dir(userConfigPath())
1035 for rel, expectedContent := range filesToWrite {
1036 if rel == "config.toml" {
1037 continue
1038 }
1039 newPath := filepath.Join(newDir, rel)
1040 data, err := os.ReadFile(newPath)
1041 if err != nil {
1042 t.Errorf("expected file %s to be migrated, but got error: %v", rel, err)
1043 continue
1044 }
1045 if string(data) != expectedContent {
1046 t.Errorf("file %s content mismatch: got %q, want %q", rel, string(data), expectedContent)
1047 }
1048 }
1049 if runtime.GOOS != "windows" {
1050 for _, check := range []struct {
1051 rel string
1052 perm os.FileMode
1053 }{
1054 {rel: "sessions", perm: 0o700},
1055 {rel: "sessions/s1.json", perm: 0o600},
1056 {rel: "hooks.json", perm: 0o600},
1057 } {
1058 info, err := os.Stat(filepath.Join(newDir, check.rel))
1059 if err != nil {
1060 t.Fatalf("stat migrated %s: %v", check.rel, err)
1061 }
1062 if got := info.Mode().Perm(); got != check.perm {
1063 t.Fatalf("migrated %s mode = %o, want %o", check.rel, got, check.perm)
1064 }
1065 }
1066 }
1067 }
1068
1069 func TestMigrateLegacyCredentialsFileImportIgnoresKeyringMarker(t *testing.T) {
1070 _, dest, _ := legacyHome(t)
1071 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
1072 t.Fatal(err)
1073 }
1074 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
1075 t.Fatal(err)
1076 }
1077 if err := markLegacyKeyringMigrationDone("DEEPSEEK_API_KEY"); err != nil {
1078 t.Fatal(err)
1079 }
1080 paths := legacyCredentialsPaths()
1081 if len(paths) == 0 {
1082 t.Skip("no legacy credentials paths on this platform")
1083 }
1084 target := paths[0]
1085 if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
1086 t.Fatal(err)
1087 }
1088 if err := os.WriteFile(target, []byte("DEEPSEEK_API_KEY=sk-from-file\n"), 0o600); err != nil {
1089 t.Fatal(err)
1090 }
1091
1092 oldLookup := legacyKeyringProbeLookup
1093 legacyKeyringProbeLookup = func(context.Context, string) legacyKeyringOutcome {
1094 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
1095 }
1096 t.Cleanup(func() {
1097 legacyKeyringProbeLookup = oldLookup
1098 })
1099
1100 if err := MigrateLegacyCredentialsForRoot("."); err != nil {
1101 t.Fatalf("MigrateLegacyCredentialsForRoot: %v", err)
1102 }
1103 data, err := os.ReadFile(UserCredentialsPath())
1104 if err != nil {
1105 t.Fatalf("read migrated credentials: %v", err)
1106 }
1107 if !strings.Contains(string(data), "sk-from-file") {
1108 t.Fatalf("file import blocked by keyring marker: %q", data)
1109 }
1110 }
1111
1112 func TestMigrateLegacyCredentialsErrorDoesNotWriteKeyringMarker(t *testing.T) {
1113 legacyHome(t)
1114 oldLookup := legacyKeyringProbeLookup
1115 legacyKeyringProbeLookup = func(context.Context, string) legacyKeyringOutcome {
1116 return legacyKeyringOutcome{Status: legacyKeyringError}
1117 }
1118 t.Cleanup(func() {
1119 legacyKeyringProbeLookup = oldLookup
1120 })
1121
1122 if err := MigrateLegacyCredentialsForRoot("."); err != nil {
1123 t.Fatalf("MigrateLegacyCredentialsForRoot: %v", err)
1124 }
1125 if legacyKeyringMigrationDone("DEEPSEEK_API_KEY") {
1126 t.Fatal("keyring error must not write a migration-done marker")
1127 }
1128 if credentialCurrentStoreHasKey("DEEPSEEK_API_KEY") {
1129 t.Fatal("keyring error must not store a credential")
1130 }
1131 }
1132
1132 lines GO