返回 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 TestMigrateImportsLegacyV1HomeTOMLBeforeJSON(t *testing.T) {
463 srcJSON, dest, home := legacyHome(t)
464 legacyTOML := filepath.Join(home, ".reasonix", "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
471 [[plugins]]
472 name = "legacy-home-v1"
473 command = "legacy-home-bin"
474 `), 0o644); err != nil {
475 t.Fatal(err)
476 }
477 writeLegacy(t, srcJSON, `{"apiKey":"sk-json-should-not-win","mcpServers":{"json":{"command":"json-bin"}}}`)
478
479 res, err := MigrateLegacyIfNeeded()
480 if err != nil {
481 t.Fatalf("migrate: %v", err)
482 }
483 if res == nil || res.From != legacyTOML {
484 t.Fatalf("expected home v1 TOML migration, got %+v", res)
485 }
486
487 got, err := os.ReadFile(dest)
488 if err != nil {
489 t.Fatalf("read migrated config: %v", err)
490 }
491 text := string(got)
492 if !strings.Contains(text, `name = "legacy-home-v1"`) {
493 t.Fatalf("home v1 plugin was not migrated:\n%s", text)
494 }
495 if strings.Contains(text, `name = "json"`) {
496 t.Fatalf("lower-priority v0.5 JSON should not be merged when v1 TOML exists:\n%s", text)
497 }
498 }
499
500 func TestLoadFallsBackToLegacyOSConfigWhenPrimaryMissing(t *testing.T) {
501 _, dest, _ := legacyHome(t)
502 legacy := legacyUserConfigPath()
503 if legacy == "" {
504 t.Skip("legacy OS config path matches primary path on this platform")
505 }
506 if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
507 t.Fatal(err)
508 }
509 if err := os.WriteFile(legacy, []byte(`default_model = "legacy-provider/legacy-model"`), 0o644); err != nil {
510 t.Fatal(err)
511 }
512
513 if source := SourcePath(); source != legacy {
514 t.Fatalf("SourcePath() = %q, want legacy path %q", source, legacy)
515 }
516 cfg, err := Load()
517 if err != nil {
518 t.Fatalf("Load: %v", err)
519 }
520 if cfg.DefaultModel != "legacy-provider/legacy-model" {
521 t.Fatalf("DefaultModel = %q, want legacy value", cfg.DefaultModel)
522 }
523 if _, err := os.Stat(dest); !os.IsNotExist(err) {
524 t.Fatalf("Load fallback should not create primary config, stat err=%v", err)
525 }
526 }
527
528 func TestLoadPrefersPrimaryConfigOverLegacyOSConfig(t *testing.T) {
529 _, dest, _ := legacyHome(t)
530 legacy := legacyUserConfigPath()
531 if legacy == "" {
532 t.Skip("legacy OS config path matches primary path on this platform")
533 }
534 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
535 t.Fatal(err)
536 }
537 if err := os.WriteFile(dest, []byte(`default_model = "primary-provider/primary-model"`), 0o644); err != nil {
538 t.Fatal(err)
539 }
540 if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
541 t.Fatal(err)
542 }
543 if err := os.WriteFile(legacy, []byte(`default_model = "legacy-provider/legacy-model"`), 0o644); err != nil {
544 t.Fatal(err)
545 }
546
547 if source := SourcePath(); source != dest {
548 t.Fatalf("SourcePath() = %q, want primary path %q", source, dest)
549 }
550 cfg, err := Load()
551 if err != nil {
552 t.Fatalf("Load: %v", err)
553 }
554 if cfg.DefaultModel != "primary-provider/primary-model" {
555 t.Fatalf("DefaultModel = %q, want primary value", cfg.DefaultModel)
556 }
557 }
558
559 func TestMigrateImportsLegacyOSConfigToPrimaryConfig(t *testing.T) {
560 _, dest, _ := legacyHome(t)
561 legacy := legacyUserConfigPath()
562 if legacy == "" {
563 t.Skip("legacy OS config path matches primary path on this platform")
564 }
565 if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
566 t.Fatal(err)
567 }
568 if err := os.WriteFile(legacy, []byte(`
569 default_model = "legacy-provider/legacy-model"
570
571 [[plugins]]
572 name = "legacy-os"
573 command = "legacy-os-bin"
574 `), 0o644); err != nil {
575 t.Fatal(err)
576 }
577
578 res, err := MigrateLegacyIfNeeded()
579 if err != nil {
580 t.Fatalf("migrate: %v", err)
581 }
582 if res == nil || res.From != legacy || res.To != dest {
583 t.Fatalf("migration result = %+v, want %s -> %s", res, legacy, dest)
584 }
585 got, err := os.ReadFile(dest)
586 if err != nil {
587 t.Fatalf("read migrated config: %v", err)
588 }
589 if !strings.Contains(string(got), `name = "legacy-os"`) {
590 t.Fatalf("dest missing legacy plugin:\n%s", got)
591 }
592 if _, err := os.Stat(legacy); err != nil {
593 t.Fatalf("legacy file must remain untouched: %v", err)
594 }
595 }
596
597 func TestMigrateImportsLegacyXDGConfigToPrimaryConfig(t *testing.T) {
598 if runtime.GOOS == "windows" {
599 t.Skip("legacy XDG paths are Unix-only")
600 }
601 _, dest, home := legacyHome(t)
602 legacy := filepath.Join(home, ".config", "reasonix", "config.toml")
603 if samePath(legacy, dest) {
604 t.Skip("legacy XDG config path matches primary path on this platform")
605 }
606 if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
607 t.Fatal(err)
608 }
609 if err := os.WriteFile(legacy, []byte(`
610 default_model = "legacy-xdg-provider/legacy-xdg-model"
611
612 [[plugins]]
613 name = "legacy-xdg"
614 command = "legacy-xdg-bin"
615 `), 0o644); err != nil {
616 t.Fatal(err)
617 }
618
619 res, err := MigrateLegacyIfNeeded()
620 if err != nil {
621 t.Fatalf("migrate: %v", err)
622 }
623 if res == nil || res.From != legacy || res.To != dest {
624 t.Fatalf("migration result = %+v, want %s -> %s", res, legacy, dest)
625 }
626 got, err := os.ReadFile(dest)
627 if err != nil {
628 t.Fatalf("read migrated config: %v", err)
629 }
630 if !strings.Contains(string(got), `name = "legacy-xdg"`) {
631 t.Fatalf("dest missing legacy XDG plugin:\n%s", got)
632 }
633 }
634
635 func TestMigrateImportsLegacyCredentialsEvenWhenPrimaryConfigExists(t *testing.T) {
636 _, dest, _ := legacyHome(t)
637 legacy := legacyUserConfigPath()
638 if legacy == "" {
639 t.Skip("legacy OS config path matches primary path on this platform")
640 }
641 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
642 t.Fatal(err)
643 }
644 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
645 t.Fatal(err)
646 }
647 legacyCred := filepath.Join(filepath.Dir(legacy), "credentials")
648 if err := os.MkdirAll(filepath.Dir(legacyCred), 0o755); err != nil {
649 t.Fatal(err)
650 }
651 if err := os.WriteFile(legacyCred, []byte("DEEPSEEK_API_KEY=sk-old-creds\n"), 0o600); err != nil {
652 t.Fatal(err)
653 }
654
655 res, err := MigrateLegacyIfNeeded()
656 if err != nil {
657 t.Fatalf("migrate: %v", err)
658 }
659 if res != nil {
660 t.Fatalf("primary config exists, config migration should be skipped, got %+v", res)
661 }
662 data, err := os.ReadFile(UserCredentialsPath())
663 if err != nil {
664 t.Fatalf("read migrated credentials: %v", err)
665 }
666 if string(data) != "DEEPSEEK_API_KEY=sk-old-creds\n" {
667 t.Fatalf("migrated credentials = %q", data)
668 }
669 }
670
671 func TestMigrateImportsLegacyKeyringCredentials(t *testing.T) {
672 legacyHome(t)
673 old := legacyKeyringProbeLookup
674 legacyKeyringProbeLookup = func(_ context.Context, key string) legacyKeyringOutcome {
675 if key == "DEEPSEEK_API_KEY" {
676 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: "sk-old-keyring"}
677 }
678 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
679 }
680 t.Cleanup(func() { legacyKeyringProbeLookup = old })
681
682 res, err := MigrateLegacyIfNeeded()
683 if err != nil {
684 t.Fatalf("migrate: %v", err)
685 }
686 if res != nil {
687 t.Fatalf("no config migration should be needed, got %+v", res)
688 }
689 data, err := os.ReadFile(UserCredentialsPath())
690 if err != nil {
691 t.Fatalf("read migrated credentials: %v", err)
692 }
693 if string(data) != "DEEPSEEK_API_KEY=sk-old-keyring\n" {
694 t.Fatalf("migrated credentials = %q", data)
695 }
696 }
697
698 func TestMigrateLegacyCredentialsUsesWorkspaceRootForKeyring(t *testing.T) {
699 _, dest, _ := legacyHome(t)
700 project := t.TempDir()
701 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
702 t.Fatal(err)
703 }
704 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
705 t.Fatal(err)
706 }
707 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
708 default_model = "custom/m"
709 [[providers]]
710 name = "custom"
711 kind = "openai"
712 base_url = "https://example.invalid/v1"
713 model = "m"
714 api_key_env = "WORKSPACE_ONLY_KEY"
715 `), 0o644); err != nil {
716 t.Fatal(err)
717 }
718 old := legacyKeyringProbeLookup
719 legacyKeyringProbeLookup = func(_ context.Context, key string) legacyKeyringOutcome {
720 if key == "WORKSPACE_ONLY_KEY" {
721 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: "sk-workspace"}
722 }
723 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
724 }
725 t.Cleanup(func() { legacyKeyringProbeLookup = old })
726
727 if err := MigrateLegacyCredentialsForRoot(project); err != nil {
728 t.Fatalf("MigrateLegacyCredentialsForRoot: %v", err)
729 }
730 data, err := os.ReadFile(UserCredentialsPath())
731 if err != nil {
732 t.Fatalf("read migrated credentials: %v", err)
733 }
734 if string(data) != "WORKSPACE_ONLY_KEY=sk-workspace\n" {
735 t.Fatalf("migrated credentials = %q", data)
736 }
737 }
738
739 func TestMigrateLegacyCredentialsSkipsKeyringWhenIsolated(t *testing.T) {
740 home := t.TempDir()
741 isolated := filepath.Join(home, "isolated-home")
742 t.Setenv("HOME", home)
743 t.Setenv("USERPROFILE", home)
744 t.Setenv("AppData", filepath.Join(home, "AppData"))
745 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
746 t.Setenv("REASONIX_HOME", isolated)
747 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
748
749 old := legacyKeyringProbeLookup
750 legacyKeyringProbeLookup = func(_ context.Context, key string) legacyKeyringOutcome {
751 if key == "DEEPSEEK_API_KEY" {
752 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: "legacy-keyring-value"}
753 }
754 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
755 }
756 t.Cleanup(func() { legacyKeyringProbeLookup = old })
757
758 if err := MigrateLegacyCredentialsForRoot("."); err != nil {
759 t.Fatalf("MigrateLegacyCredentialsForRoot: %v", err)
760 }
761 if _, err := os.Stat(UserCredentialsPath()); !os.IsNotExist(err) {
762 t.Fatalf("isolated runtime imported legacy credentials to %s; stat err=%v", UserCredentialsPath(), err)
763 }
764 }
765
766 func TestMigrateLegacyCredentialsDoesNotReimportClearedKey(t *testing.T) {
767 _, dest, _ := legacyHome(t)
768 legacy := legacyUserConfigPath()
769 if legacy == "" {
770 t.Skip("legacy OS config path matches primary path on this platform")
771 }
772 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
773 t.Fatal(err)
774 }
775 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
776 t.Fatal(err)
777 }
778 legacyCred := filepath.Join(filepath.Dir(legacy), "credentials")
779 if err := os.MkdirAll(filepath.Dir(legacyCred), 0o755); err != nil {
780 t.Fatal(err)
781 }
782 if err := os.WriteFile(legacyCred, []byte("DEEPSEEK_API_KEY=sk-old-creds\n"), 0o600); err != nil {
783 t.Fatal(err)
784 }
785
786 if err := MigrateLegacyCredentialsForRoot("."); err != nil {
787 t.Fatalf("initial migrate: %v", err)
788 }
789 if err := RemoveCredential("DEEPSEEK_API_KEY"); err != nil {
790 t.Fatalf("RemoveCredential: %v", err)
791 }
792 if err := MigrateLegacyCredentialsForRoot("."); err != nil {
793 t.Fatalf("second migrate: %v", err)
794 }
795 data, err := os.ReadFile(UserCredentialsPath())
796 if err != nil {
797 t.Fatalf("read current credentials: %v", err)
798 }
799 if strings.Contains(string(data), "sk-old-creds") || CredentialStored("DEEPSEEK_API_KEY") {
800 t.Fatalf("cleared key was re-imported:\n%s", data)
801 }
802 if !strings.Contains(string(data), credentialClearedPrefix+"DEEPSEEK_API_KEY") {
803 t.Fatalf("cleared marker missing:\n%s", data)
804 }
805 }
806
807 func TestMigrateSkipsLegacyCredentialsAlreadyInCurrentAutoStore(t *testing.T) {
808 _, dest, _ := legacyHome(t)
809 t.Setenv("REASONIX_CREDENTIALS_STORE", "")
810 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
811 t.Fatal(err)
812 }
813 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
814 t.Fatal(err)
815 }
816 currentCred := UserCredentialsPath()
817 if err := os.MkdirAll(filepath.Dir(currentCred), 0o755); err != nil {
818 t.Fatal(err)
819 }
820 if err := os.WriteFile(currentCred, []byte("DEEPSEEK_API_KEY=sk-current\n"), 0o600); err != nil {
821 t.Fatal(err)
822 }
823
824 legacyPaths := legacyCredentialsPaths()
825 if len(legacyPaths) == 0 {
826 t.Skip("no legacy credentials path on this platform")
827 }
828 legacyCred := legacyPaths[0]
829 if err := os.MkdirAll(filepath.Dir(legacyCred), 0o755); err != nil {
830 t.Fatal(err)
831 }
832 if err := os.WriteFile(legacyCred, []byte("DEEPSEEK_API_KEY=sk-stale\n"), 0o600); err != nil {
833 t.Fatal(err)
834 }
835
836 res, err := MigrateLegacyIfNeeded()
837 if err != nil {
838 t.Fatalf("migrate: %v", err)
839 }
840 if res != nil {
841 t.Fatalf("primary config exists, config migration should be skipped, got %+v", res)
842 }
843 data, err := os.ReadFile(currentCred)
844 if err != nil {
845 t.Fatalf("read current credentials: %v", err)
846 }
847 if string(data) != "DEEPSEEK_API_KEY=sk-current\n" {
848 t.Fatalf("current credentials were overwritten: %q", data)
849 }
850 }
851
852 func TestMigrateImportsLegacyStateHomeDotEnvCredentials(t *testing.T) {
853 _, dest, _ := legacyHome(t)
854 state := t.TempDir()
855 t.Setenv("REASONIX_STATE_HOME", state)
856 t.Setenv("REASONIX_CREDENTIALS_STORE", "")
857 os.Unsetenv("REASONIX_CREDENTIALS_STORE")
858 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
859 t.Fatal(err)
860 }
861 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
862 t.Fatal(err)
863 }
864 if err := os.WriteFile(filepath.Join(state, ".env"), []byte("DEEPSEEK_API_KEY=state-env-value\n"), 0o600); err != nil {
865 t.Fatal(err)
866 }
867
868 currentCred := UserCredentialsPath()
869 if strings.HasPrefix(currentCred, state) {
870 t.Fatalf("current credentials path should not be under REASONIX_STATE_HOME: %q", currentCred)
871 }
872 res, err := MigrateLegacyIfNeeded()
873 if err != nil {
874 t.Fatalf("migrate: %v", err)
875 }
876 if res != nil {
877 t.Fatalf("primary config exists, config migration should be skipped, got %+v", res)
878 }
879 data, err := os.ReadFile(currentCred)
880 if err != nil {
881 t.Fatalf("read current credentials: %v", err)
882 }
883 if string(data) != "DEEPSEEK_API_KEY=state-env-value\n" {
884 t.Fatalf("migrated credentials = %q", data)
885 }
886 }
887
888 func TestMigrateNoLegacyIsNoop(t *testing.T) {
889 legacyHome(t)
890 res, err := MigrateLegacyIfNeeded()
891 if err != nil || res != nil {
892 t.Errorf("no legacy install should be a silent no-op, got res=%+v err=%v", res, err)
893 }
894 }
895
896 func TestMigrateToleratesUTF8BOM(t *testing.T) {
897 src, _, _ := legacyHome(t)
898 writeLegacy(t, src, "\ufeff"+`{"apiKey":"sk-bom"}`)
899 res, err := MigrateLegacyIfNeeded()
900 if err != nil {
901 t.Fatalf("a BOM-prefixed legacy config must still parse: %v", err)
902 }
903 if res == nil || !res.KeyToEnv {
904 t.Fatalf("BOM-prefixed config did not migrate: %+v", res)
905 }
906 data, _ := os.ReadFile(UserCredentialsPath())
907 if !strings.Contains(string(data), "DEEPSEEK_API_KEY=sk-bom") {
908 t.Errorf("key not migrated from BOM-prefixed config: %q", data)
909 }
910 }
911
912 func TestMigrateCustomBaseURLWarns(t *testing.T) {
913 src, _, _ := legacyHome(t)
914 writeLegacy(t, src, `{"apiKey":"sk-x","baseUrl":"https://my-proxy.example/v1"}`)
915 res, err := MigrateLegacyIfNeeded()
916 if err != nil {
917 t.Fatalf("migrate: %v", err)
918 }
919 if len(res.Warnings) == 0 {
920 t.Error("a non-DeepSeek base_url should produce a warning")
921 }
922 cfg, err := Load()
923 if err != nil {
924 t.Fatalf("load migrated config: %v", err)
925 }
926 for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
927 p, ok := cfg.Provider(name)
928 if !ok || p.Kind != "openai" || p.BaseURL != "https://my-proxy.example/v1" ||
929 p.Thinking != "" || p.WebSearch != nil || len(p.SupportedEfforts) != 0 || p.DefaultEffort != "" {
930 t.Fatalf("%s base_url was not migrated: %+v", name, p)
931 }
932 }
933 }
934
935 func TestMigrateOfficialLegacyBaseURLUsesAnthropicEndpoint(t *testing.T) {
936 for _, baseURL := range []string{
937 "https://api.deepseek.com",
938 "https://api.deepseek.com/v1/",
939 } {
940 t.Run(baseURL, func(t *testing.T) {
941 src, _, _ := legacyHome(t)
942 writeLegacy(t, src, `{"apiKey":"sk-official","baseUrl":"`+baseURL+`"}`)
943
944 if _, err := MigrateLegacyIfNeeded(); err != nil {
945 t.Fatalf("migrate: %v", err)
946 }
947 cfg, err := Load()
948 if err != nil {
949 t.Fatalf("load migrated config: %v", err)
950 }
951 for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
952 p, ok := cfg.Provider(name)
953 if !ok || p.Kind != "anthropic" || p.BaseURL != deepSeekAnthropicBaseURL || !EffectiveWebSearch(p) {
954 t.Fatalf("%s official migration = %+v, want Anthropic endpoint with web search", name, p)
955 }
956 }
957 })
958 }
959 }
960
961 func TestMigrateSupportData(t *testing.T) {
962 if runtime.GOOS == "windows" {
963 t.Skip("skipping since legacyOSSupportDir equals current reasonixHomeDir on Windows")
964 }
965 home := t.TempDir()
966 t.Setenv("HOME", home)
967 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
968 t.Setenv("USERPROFILE", home)
969 t.Setenv("AppData", filepath.Join(home, "AppData"))
970
971 legacyConf := legacyUserConfigPath()
972 if legacyConf == "" {
973 t.Skip("skipping because legacy config path is empty")
974 }
975 legacyDir := filepath.Dir(legacyConf)
976
977 // Write data to the legacy support directory
978 filesToWrite := map[string]string{
979 "config.toml": "language = \"zh\"",
980 "hooks.json": `{"hook":"test"}`,
981 "sessions/s1.json": `{"id":"s1"}`,
982 "projects/p1/sessions/s2.json": `{"id":"s2"}`,
983 "skills/custom.md": `custom skill`,
984 "archive/a1.json": `{"compacted": true}`,
985 }
986 for rel, content := range filesToWrite {
987 path := filepath.Join(legacyDir, rel)
988 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
989 t.Fatal(err)
990 }
991 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
992 t.Fatal(err)
993 }
994 }
995 if runtime.GOOS != "windows" {
996 if err := os.Chmod(filepath.Join(legacyDir, "sessions"), 0o700); err != nil {
997 t.Fatal(err)
998 }
999 if err := os.Chmod(filepath.Join(legacyDir, "sessions", "s1.json"), 0o600); err != nil {
1000 t.Fatal(err)
1001 }
1002 if err := os.Chmod(filepath.Join(legacyDir, "hooks.json"), 0o600); err != nil {
1003 t.Fatal(err)
1004 }
1005 }
1006
1007 res, err := MigrateLegacyIfNeeded()
1008 if err != nil {
1009 t.Fatalf("MigrateLegacyIfNeeded failed: %v", err)
1010 }
1011 if res == nil {
1012 t.Fatal("expected migration result, got nil")
1013 }
1014
1015 newDir := filepath.Dir(userConfigPath())
1016 for rel, expectedContent := range filesToWrite {
1017 if rel == "config.toml" {
1018 continue
1019 }
1020 newPath := filepath.Join(newDir, rel)
1021 data, err := os.ReadFile(newPath)
1022 if err != nil {
1023 t.Errorf("expected file %s to be migrated, but got error: %v", rel, err)
1024 continue
1025 }
1026 if string(data) != expectedContent {
1027 t.Errorf("file %s content mismatch: got %q, want %q", rel, string(data), expectedContent)
1028 }
1029 }
1030 if runtime.GOOS != "windows" {
1031 for _, check := range []struct {
1032 rel string
1033 perm os.FileMode
1034 }{
1035 {rel: "sessions", perm: 0o700},
1036 {rel: "sessions/s1.json", perm: 0o600},
1037 {rel: "hooks.json", perm: 0o600},
1038 } {
1039 info, err := os.Stat(filepath.Join(newDir, check.rel))
1040 if err != nil {
1041 t.Fatalf("stat migrated %s: %v", check.rel, err)
1042 }
1043 if got := info.Mode().Perm(); got != check.perm {
1044 t.Fatalf("migrated %s mode = %o, want %o", check.rel, got, check.perm)
1045 }
1046 }
1047 }
1048 }
1049
1050 func TestMigrateLegacyCredentialsFileImportIgnoresKeyringMarker(t *testing.T) {
1051 _, dest, _ := legacyHome(t)
1052 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
1053 t.Fatal(err)
1054 }
1055 if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
1056 t.Fatal(err)
1057 }
1058 if err := markLegacyKeyringMigrationDone("DEEPSEEK_API_KEY"); err != nil {
1059 t.Fatal(err)
1060 }
1061 paths := legacyCredentialsPaths()
1062 if len(paths) == 0 {
1063 t.Skip("no legacy credentials paths on this platform")
1064 }
1065 target := paths[0]
1066 if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
1067 t.Fatal(err)
1068 }
1069 if err := os.WriteFile(target, []byte("DEEPSEEK_API_KEY=sk-from-file\n"), 0o600); err != nil {
1070 t.Fatal(err)
1071 }
1072
1073 oldLookup := legacyKeyringProbeLookup
1074 legacyKeyringProbeLookup = func(context.Context, string) legacyKeyringOutcome {
1075 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
1076 }
1077 t.Cleanup(func() {
1078 legacyKeyringProbeLookup = oldLookup
1079 })
1080
1081 if err := MigrateLegacyCredentialsForRoot("."); err != nil {
1082 t.Fatalf("MigrateLegacyCredentialsForRoot: %v", err)
1083 }
1084 data, err := os.ReadFile(UserCredentialsPath())
1085 if err != nil {
1086 t.Fatalf("read migrated credentials: %v", err)
1087 }
1088 if !strings.Contains(string(data), "sk-from-file") {
1089 t.Fatalf("file import blocked by keyring marker: %q", data)
1090 }
1091 }
1092
1093 func TestMigrateLegacyCredentialsErrorDoesNotWriteKeyringMarker(t *testing.T) {
1094 legacyHome(t)
1095 oldLookup := legacyKeyringProbeLookup
1096 legacyKeyringProbeLookup = func(context.Context, string) legacyKeyringOutcome {
1097 return legacyKeyringOutcome{Status: legacyKeyringError}
1098 }
1099 t.Cleanup(func() {
1100 legacyKeyringProbeLookup = oldLookup
1101 })
1102
1103 if err := MigrateLegacyCredentialsForRoot("."); err != nil {
1104 t.Fatalf("MigrateLegacyCredentialsForRoot: %v", err)
1105 }
1106 if legacyKeyringMigrationDone("DEEPSEEK_API_KEY") {
1107 t.Fatal("keyring error must not write a migration-done marker")
1108 }
1109 if credentialCurrentStoreHasKey("DEEPSEEK_API_KEY") {
1110 t.Fatal("keyring error must not store a credential")
1111 }
1112 }
1113
1113 lines GO