返回 DeepSeek-Reasonix
mcpjson_test.go
根目录 / internal / config / mcpjson_test.go
1 package config
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 fileencoding "reasonix/internal/fileutil/encoding"
14 )
15
16 func TestLoadMCPJSON(t *testing.T) {
17 dir := t.TempDir()
18 path := filepath.Join(dir, mcpJSONFile)
19 doc := `{
20 "mcpServers": {
21 "stripe": {
22 "type": "http",
23 "url": "https://mcp.stripe.com",
24 "headers": { "Authorization": "Bearer ${STRIPE_KEY}" }
25 },
26 "filesystem": {
27 "command": "npx",
28 "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
29 "env": { "FOO": "bar" }
30 }
31 }
32 }`
33 if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
34 t.Fatal(err)
35 }
36
37 got, err := loadMCPJSON(path)
38 if err != nil {
39 t.Fatal(err)
40 }
41 // Sorted by name: filesystem before stripe.
42 if len(got) != 2 || got[0].Name != "filesystem" || got[1].Name != "stripe" {
43 t.Fatalf("entries = %+v, want [filesystem stripe] sorted", got)
44 }
45 fs := got[0]
46 if fs.Command != "npx" || len(fs.Args) != 3 || fs.Env["FOO"] != "bar" {
47 t.Errorf("filesystem decoded wrong: %+v", fs)
48 }
49 if fs.Source != MCPSourceProjectMCPJSON {
50 t.Errorf("filesystem source = %q, want project .mcp.json", fs.Source)
51 }
52 st := got[1]
53 if st.Type != "http" || st.URL != "https://mcp.stripe.com" ||
54 st.Headers["Authorization"] != "Bearer ${STRIPE_KEY}" {
55 t.Errorf("stripe decoded wrong: %+v", st)
56 }
57 }
58
59 func TestLoadMCPJSONDecodesGB18030(t *testing.T) {
60 dir := t.TempDir()
61 path := filepath.Join(dir, mcpJSONFile)
62 doc := `{"mcpServers":{"local":{"command":"工具.exe","env":{"LABEL":"中文"}}}}`
63 if err := os.WriteFile(path, fileencoding.Encode(doc, fileencoding.GB18030), 0o644); err != nil {
64 t.Fatal(err)
65 }
66
67 got, err := loadMCPJSON(path)
68 if err != nil {
69 t.Fatal(err)
70 }
71 if len(got) != 1 || got[0].Command != "工具.exe" || got[0].Env["LABEL"] != "中文" {
72 t.Fatalf("decoded .mcp.json entries = %+v", got)
73 }
74 }
75
76 func TestMCPJSONDropsRemovedTrustedReadOnlyToolsSetting(t *testing.T) {
77 dir := t.TempDir()
78 path := filepath.Join(dir, mcpJSONFile)
79 if err := os.WriteFile(path, []byte(`{"mcpServers":{"github":{"command":"old","trusted_read_only_tools":["issue_read"]}}}`), 0o644); err != nil {
80 t.Fatal(err)
81 }
82 if _, err := UpsertMCPJSONPlugin(path, PluginEntry{
83 Name: "github",
84 Command: "npx",
85 Args: []string{"-y", "@modelcontextprotocol/server-github"},
86 }); err != nil {
87 t.Fatal(err)
88 }
89 body, err := os.ReadFile(path)
90 if err != nil {
91 t.Fatal(err)
92 }
93 if strings.Contains(string(body), "trusted_read_only_tools") {
94 t.Fatalf("updated .mcp.json retained removed reader setting:\n%s", body)
95 }
96 got, err := loadMCPJSON(path)
97 if err != nil {
98 t.Fatal(err)
99 }
100 if len(got) != 1 {
101 t.Fatalf("entries = %+v, want one github entry", got)
102 }
103 }
104
105 func TestMCPJSONCallTimeoutsRoundTrip(t *testing.T) {
106 dir := t.TempDir()
107 path := filepath.Join(dir, mcpJSONFile)
108 if err := os.WriteFile(path, []byte(`{
109 "mcpServers": {
110 "maker": {
111 "command": "old-maker",
112 "unknown_field": true
113 }
114 }
115 }`), 0o644); err != nil {
116 t.Fatal(err)
117 }
118 if _, err := UpsertMCPJSONPlugin(path, PluginEntry{
119 Name: "maker",
120 Command: "maker-mcp",
121 StartupTimeoutSeconds: 60,
122 CallTimeoutSeconds: 600,
123 ToolTimeoutSeconds: map[string]int{
124 "generate/video": 1800,
125 "search": 120,
126 "ignored_zero": 0,
127 },
128 }); err != nil {
129 t.Fatal(err)
130 }
131 got, err := loadMCPJSON(path)
132 if err != nil {
133 t.Fatal(err)
134 }
135 if len(got) != 1 {
136 t.Fatalf("entries = %+v, want one maker entry", got)
137 }
138 if got[0].CallTimeoutSeconds != 600 {
139 t.Fatalf("call_timeout_seconds = %d, want 600", got[0].CallTimeoutSeconds)
140 }
141 if got[0].StartupTimeoutSeconds != 60 {
142 t.Fatalf("startup_timeout_seconds = %d, want 60", got[0].StartupTimeoutSeconds)
143 }
144 if got[0].ToolTimeoutSeconds["generate/video"] != 1800 || got[0].ToolTimeoutSeconds["search"] != 120 {
145 t.Fatalf("tool_timeout_seconds = %+v, want generate/video=1800 search=120", got[0].ToolTimeoutSeconds)
146 }
147 if _, ok := got[0].ToolTimeoutSeconds["ignored_zero"]; ok {
148 t.Fatalf("zero timeout should not be written: %+v", got[0].ToolTimeoutSeconds)
149 }
150
151 root, servers, err := readMCPJSONRaw(path)
152 if err != nil {
153 t.Fatal(err)
154 }
155 if len(root) == 0 || len(servers) != 1 {
156 t.Fatalf("raw root/servers = %+v/%+v", root, servers)
157 }
158 var server map[string]any
159 if err := json.Unmarshal(servers["maker"], &server); err != nil {
160 t.Fatal(err)
161 }
162 if server["unknown_field"] != true {
163 t.Fatalf("unknown per-server field was not preserved: %+v", server)
164 }
165 }
166
167 func TestMCPJSONUpdateRemovesRetiredApprovalFieldsAndPreservesUnknownFields(t *testing.T) {
168 path := filepath.Join(t.TempDir(), mcpJSONFile)
169 if err := os.WriteFile(path, []byte(`{
170 "mcpServers": {
171 "admin": {
172 "command": "old-admin-mcp",
173 "future_server_field": {"version": 2},
174 "tools": {
175 "wipe": {"approval_mode": "prompt", "enabled": false, "future": {"audit": true}},
176 "external_only": {"enabled": false},
177 "remove_keep": {"approval_mode": "writes", "enabled": true},
178 "remove_entirely": {"approval_mode": "approve"}
179 }
180 }
181 }
182 }`), 0o644); err != nil {
183 t.Fatal(err)
184 }
185
186 if _, err := UpsertMCPJSONPlugin(path, PluginEntry{Name: "admin", Command: "admin-mcp"}); err != nil {
187 t.Fatal(err)
188 }
189
190 root, servers, err := readMCPJSONRaw(path)
191 if err != nil {
192 t.Fatal(err)
193 }
194 if len(root) == 0 {
195 t.Fatal("raw root is empty")
196 }
197 var server map[string]json.RawMessage
198 if err := json.Unmarshal(servers["admin"], &server); err != nil {
199 t.Fatal(err)
200 }
201 if _, ok := server["future_server_field"]; !ok {
202 t.Fatal("unknown per-server field was removed")
203 }
204 var tools map[string]map[string]json.RawMessage
205 if err := json.Unmarshal(server["tools"], &tools); err != nil {
206 t.Fatal(err)
207 }
208 if len(tools) != 3 {
209 t.Fatalf("raw tools = %+v, want wipe, external_only, and remove_keep", tools)
210 }
211 if _, ok := tools["wipe"]["enabled"]; !ok {
212 t.Fatal("known tool lost external enabled field")
213 }
214 if _, ok := tools["wipe"]["future"]; !ok {
215 t.Fatal("known tool lost future nested field")
216 }
217 if _, ok := tools["external_only"]; !ok {
218 t.Fatal("unknown-only tool entry was removed")
219 }
220 if _, ok := tools["remove_keep"]["approval_mode"]; ok {
221 t.Fatal("removed Reasonix approval mode survived")
222 }
223 if _, ok := tools["remove_keep"]["enabled"]; !ok {
224 t.Fatal("removing approval mode removed external fields")
225 }
226 if _, ok := tools["remove_entirely"]; ok {
227 t.Fatal("approval-only entry should be removed when its policy is cleared")
228 }
229 }
230
231 func TestNormalizePluginCommandLine(t *testing.T) {
232 cases := []struct {
233 name string
234 in PluginEntry
235 wantCommand string
236 wantArgs []string
237 wantChanged bool
238 }{
239 {
240 name: "npx pasted with args",
241 in: PluginEntry{Name: "playwright", Command: "npx -y @playwright/mcp"},
242 wantCommand: "npx",
243 wantArgs: []string{"-y", "@playwright/mcp"},
244 wantChanged: true,
245 },
246 {
247 name: "custom command pasted with args",
248 in: PluginEntry{Name: "custom", Command: "custom-mcp --stdio"},
249 wantCommand: "custom-mcp",
250 wantArgs: []string{"--stdio"},
251 wantChanged: true,
252 },
253 {
254 name: "quoted command path",
255 in: PluginEntry{Name: "quoted", Command: `"C:\Program Files\nodejs\npx.cmd" -y @example/mcp`},
256 wantCommand: `C:\Program Files\nodejs\npx.cmd`,
257 wantArgs: []string{"-y", "@example/mcp"},
258 wantChanged: true,
259 },
260 {
261 name: "empty quoted arg preserved",
262 in: PluginEntry{Name: "empty", Command: `npx --token "" @example/mcp`},
263 wantCommand: "npx",
264 wantArgs: []string{"--token", "", "@example/mcp"},
265 wantChanged: true,
266 },
267 {
268 name: "quoted arg with spaces preserved",
269 in: PluginEntry{Name: "quoted-arg", Command: `npx --label "My Server" @example/mcp`},
270 wantCommand: "npx",
271 wantArgs: []string{"--label", "My Server", "@example/mcp"},
272 wantChanged: true,
273 },
274 {
275 name: "shell control syntax untouched",
276 in: PluginEntry{Name: "control", Command: `npx @example/mcp && rm -rf tmp`},
277 wantCommand: "npx @example/mcp && rm -rf tmp",
278 wantChanged: false,
279 },
280 {
281 name: "unquoted command path with spaces stays literal",
282 in: PluginEntry{Name: "literal", Command: `C:\Program Files\nodejs\npx.cmd`},
283 wantCommand: `C:\Program Files\nodejs\npx.cmd`,
284 wantChanged: false,
285 },
286 {
287 name: "remote entry untouched",
288 in: PluginEntry{Name: "remote", Type: "http", URL: "https://mcp.example.com/mcp", Command: "npx -y nope"},
289 wantCommand: "npx -y nope",
290 wantChanged: false,
291 },
292 }
293 for _, tc := range cases {
294 t.Run(tc.name, func(t *testing.T) {
295 got, changed := NormalizePluginCommandLine(tc.in)
296 if changed != tc.wantChanged {
297 t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
298 }
299 if got.Command != tc.wantCommand {
300 t.Fatalf("command = %q, want %q", got.Command, tc.wantCommand)
301 }
302 if strings.Join(got.Args, "\x00") != strings.Join(tc.wantArgs, "\x00") {
303 t.Fatalf("args = %v, want %v", got.Args, tc.wantArgs)
304 }
305 })
306 }
307 }
308
309 func TestParseLegacyMCPSpecSplitsCustomCommandArgs(t *testing.T) {
310 got, ok := parseLegacyMCPSpec("fs=custom-mcp --stdio")
311 if !ok {
312 t.Fatal("parseLegacyMCPSpec returned false")
313 }
314 if got.Name != "fs" || got.Command != "custom-mcp" || strings.Join(got.Args, "\x00") != "--stdio" {
315 t.Fatalf("legacy custom MCP spec = %+v, want name fs command custom-mcp args [--stdio]", got)
316 }
317 }
318
319 func TestUpsertPluginNormalizesPastedCommandLine(t *testing.T) {
320 cfg := &Config{}
321 if err := cfg.UpsertPlugin(PluginEntry{Name: "playwright", Command: "npx -y @playwright/mcp"}); err != nil {
322 t.Fatal(err)
323 }
324 if got := cfg.Plugins[0].Command; got != "npx" {
325 t.Fatalf("command = %q, want npx", got)
326 }
327 if got := cfg.Plugins[0].Args; len(got) != 2 || got[0] != "-y" || got[1] != "@playwright/mcp" {
328 t.Fatalf("args = %v, want [-y @playwright/mcp]", got)
329 }
330 }
331
332 func TestLoadMCPJSONAbsentAndMalformed(t *testing.T) {
333 dir := t.TempDir()
334
335 // Absent file: not an error, no entries.
336 got, err := loadMCPJSON(filepath.Join(dir, "missing.json"))
337 if err != nil || got != nil {
338 t.Errorf("absent file: got (%v, %v), want (nil, nil)", got, err)
339 }
340
341 // Malformed file: an error so a typo surfaces instead of dropping servers.
342 bad := filepath.Join(dir, mcpJSONFile)
343 if err := os.WriteFile(bad, []byte("{not json"), 0o644); err != nil {
344 t.Fatal(err)
345 }
346 if _, err := loadMCPJSON(bad); err == nil {
347 t.Error("malformed .mcp.json: want error, got nil")
348 }
349 }
350
351 func TestLoadMergesMCPJSON(t *testing.T) {
352 // Point the user-config and home dirs at an empty temp dir so Load picks up
353 // no global config, then chdir into a project dir holding both files.
354 empty := t.TempDir()
355 t.Setenv("HOME", empty)
356 t.Setenv("XDG_CONFIG_HOME", empty)
357 t.Chdir(t.TempDir())
358
359 toml := `[[plugins]]
360 name = "shared"
361 command = "local-bin"
362 `
363 if err := os.WriteFile("reasonix.toml", []byte(toml), 0o644); err != nil {
364 t.Fatal(err)
365 }
366 mcp := `{ "mcpServers": {
367 "shared": { "type": "http", "url": "https://override.example" },
368 "extra": { "command": "extra-bin", "auto_start": false }
369 } }`
370 if err := os.WriteFile(mcpJSONFile, []byte(mcp), 0o644); err != nil {
371 t.Fatal(err)
372 }
373
374 cfg, err := Load()
375 if err != nil {
376 t.Fatal(err)
377 }
378 byName := map[string]PluginEntry{}
379 for _, p := range cfg.Plugins {
380 byName[p.Name] = p
381 }
382 if len(byName) != 2 {
383 t.Fatalf("plugins = %+v, want shared + extra", cfg.Plugins)
384 }
385 if byName["shared"].Command != "local-bin" || byName["shared"].URL != "" {
386 t.Errorf("reasonix.toml should win the collision, got %+v", byName["shared"])
387 }
388 if byName["extra"].Command != "extra-bin" {
389 t.Errorf("extra not merged from .mcp.json, got %+v", byName["extra"])
390 }
391 if byName["extra"].AutoStart == nil || *byName["extra"].AutoStart {
392 t.Errorf("extra auto_start=false not preserved, got %+v", byName["extra"].AutoStart)
393 }
394 }
395
396 func TestLoadMergesPluginsAcrossTOMLSources(t *testing.T) {
397 root := t.TempDir()
398 t.Setenv("HOME", root)
399 t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg"))
400 t.Setenv("AppData", filepath.Join(root, "AppData")) // os.UserConfigDir reads AppData on Windows
401 t.Chdir(t.TempDir())
402
403 gpath := UserConfigPath()
404 if gpath == "" {
405 t.Fatal("UserConfigPath empty under isolated env")
406 }
407 if err := os.MkdirAll(filepath.Dir(gpath), 0o755); err != nil {
408 t.Fatal(err)
409 }
410 if err := os.WriteFile(gpath, []byte("[[plugins]]\nname = \"globalmcp\"\ncommand = \"global-bin\"\n"), 0o644); err != nil {
411 t.Fatal(err)
412 }
413 if err := os.WriteFile("reasonix.toml", []byte("[[plugins]]\nname = \"projectmcp\"\ncommand = \"project-bin\"\n"), 0o644); err != nil {
414 t.Fatal(err)
415 }
416
417 cfg, err := Load()
418 if err != nil {
419 t.Fatal(err)
420 }
421 names := map[string]bool{}
422 sources := map[string]MCPConfigSource{}
423 for _, p := range cfg.Plugins {
424 names[p.Name] = true
425 sources[p.Name] = p.Source
426 }
427 if !names["globalmcp"] || !names["projectmcp"] {
428 t.Fatalf("a project reasonix.toml [[plugins]] dropped the global config's server; got %+v", cfg.Plugins)
429 }
430 if sources["globalmcp"] != MCPSourceUserConfig || sources["projectmcp"] != MCPSourceProjectConfig {
431 t.Fatalf("plugin provenance = %+v", sources)
432 }
433 }
434
435 func TestLoadProjectMCPPriorityIsReasonixThenMCPJSONThenGlobal(t *testing.T) {
436 _, userConfig, _ := legacyHome(t)
437 root := t.TempDir()
438 if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil {
439 t.Fatal(err)
440 }
441 if err := os.WriteFile(userConfig, []byte(`
442 [[plugins]]
443 name = "shared"
444 command = "global-mcp"
445 `), 0o600); err != nil {
446 t.Fatal(err)
447 }
448 if err := os.WriteFile(filepath.Join(root, mcpJSONFile), []byte(`{
449 "mcpServers": {
450 "shared": { "command": "project-json-mcp" }
451 }
452 }`), 0o644); err != nil {
453 t.Fatal(err)
454 }
455
456 cfg, err := LoadForRoot(root)
457 if err != nil {
458 t.Fatal(err)
459 }
460 entry, ok := pluginEntryByName(cfg.Plugins, "shared")
461 if !ok || entry.Command != "project-json-mcp" || entry.Source != MCPSourceProjectMCPJSON {
462 t.Fatalf("global + .mcp.json effective entry = %+v, want project .mcp.json", entry)
463 }
464
465 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(`
466 [[plugins]]
467 name = "shared"
468 command = "project-reasonix-mcp"
469 `), 0o644); err != nil {
470 t.Fatal(err)
471 }
472 cfg, err = LoadForRoot(root)
473 if err != nil {
474 t.Fatal(err)
475 }
476 entry, ok = pluginEntryByName(cfg.Plugins, "shared")
477 if !ok || entry.Command != "project-reasonix-mcp" || entry.Source != MCPSourceProjectConfig {
478 t.Fatalf("reasonix.toml + .mcp.json + global effective entry = %+v, want project reasonix.toml", entry)
479 }
480 }
481
482 func TestUpsertPluginInSourcePreservesGlobalAndProjectBoundaries(t *testing.T) {
483 _, userConfig, _ := legacyHome(t)
484 root := t.TempDir()
485 if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil {
486 t.Fatal(err)
487 }
488 if err := os.WriteFile(userConfig, []byte(`
489 [[plugins]]
490 name = "global"
491 command = "global-old"
492 `), 0o600); err != nil {
493 t.Fatal(err)
494 }
495 projectPath := filepath.Join(root, "reasonix.toml")
496 if err := os.WriteFile(projectPath, []byte(`
497 [[plugins]]
498 name = "project"
499 command = "project-old"
500 `), 0o644); err != nil {
501 t.Fatal(err)
502 }
503
504 if path, err := UpsertPluginInSourceForRoot(root, PluginEntry{
505 Name: "global", Command: "global-new", Source: MCPSourceUserConfig,
506 }); err != nil || !samePath(path, userConfig) {
507 t.Fatalf("upsert global path=%q err=%v, want %q", path, err, userConfig)
508 }
509 if path, err := UpsertPluginInSourceForRoot(root, PluginEntry{
510 Name: "project", Command: "project-new", Source: MCPSourceProjectConfig,
511 }); err != nil || !samePath(path, projectPath) {
512 t.Fatalf("upsert project path=%q err=%v, want %q", path, err, projectPath)
513 }
514
515 globalCfg := LoadForEdit(userConfig)
516 if entry, ok := pluginEntryByName(globalCfg.Plugins, "global"); !ok || entry.Command != "global-new" {
517 t.Fatalf("global config entry = %+v, found=%v", entry, ok)
518 }
519 if _, ok := pluginEntryByName(globalCfg.Plugins, "project"); ok {
520 t.Fatalf("project MCP leaked into global config: %+v", globalCfg.Plugins)
521 }
522 projectCfg := LoadForEdit(projectPath)
523 if entry, ok := pluginEntryByName(projectCfg.Plugins, "project"); !ok || entry.Command != "project-new" {
524 t.Fatalf("project config entry = %+v, found=%v", entry, ok)
525 }
526 if _, ok := pluginEntryByName(projectCfg.Plugins, "global"); ok {
527 t.Fatalf("global MCP leaked into project config: %+v", projectCfg.Plugins)
528 }
529 }
530
531 func TestRemoveEffectivePluginRevealsLowerPriorityDeclaration(t *testing.T) {
532 _, userConfig, _ := legacyHome(t)
533 root := t.TempDir()
534 if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil {
535 t.Fatal(err)
536 }
537 if err := os.WriteFile(userConfig, []byte(`
538 [[plugins]]
539 name = "shared"
540 command = "global-mcp"
541 `), 0o600); err != nil {
542 t.Fatal(err)
543 }
544 projectPath := filepath.Join(root, "reasonix.toml")
545 if err := os.WriteFile(projectPath, []byte(`
546 [[plugins]]
547 name = "shared"
548 command = "project-reasonix-mcp"
549 `), 0o644); err != nil {
550 t.Fatal(err)
551 }
552 mcpPath := filepath.Join(root, mcpJSONFile)
553 if err := os.WriteFile(mcpPath, []byte(`{
554 "mcpServers": {
555 "shared": { "command": "project-json-mcp" }
556 }
557 }`), 0o644); err != nil {
558 t.Fatal(err)
559 }
560
561 removed, ok, path, err := RemovePluginFromEffectiveSourceForRoot(root, "shared")
562 if err != nil || !ok || removed.Source != MCPSourceProjectConfig || !samePath(path, projectPath) {
563 t.Fatalf("remove project TOML = entry:%+v removed:%v path:%q err:%v", removed, ok, path, err)
564 }
565 cfg, err := LoadForRoot(root)
566 if err != nil {
567 t.Fatal(err)
568 }
569 entry, found := pluginEntryByName(cfg.Plugins, "shared")
570 if !found || entry.Source != MCPSourceProjectMCPJSON || entry.Command != "project-json-mcp" {
571 t.Fatalf("after removing project TOML effective entry = %+v, found=%v", entry, found)
572 }
573
574 removed, ok, path, err = RemovePluginFromEffectiveSourceForRoot(root, "shared")
575 if err != nil || !ok || removed.Source != MCPSourceProjectMCPJSON || !samePath(path, mcpPath) {
576 t.Fatalf("remove project .mcp.json = entry:%+v removed:%v path:%q err:%v", removed, ok, path, err)
577 }
578 cfg, err = LoadForRoot(root)
579 if err != nil {
580 t.Fatal(err)
581 }
582 entry, found = pluginEntryByName(cfg.Plugins, "shared")
583 if !found || entry.Source != MCPSourceUserConfig || entry.Command != "global-mcp" {
584 t.Fatalf("after removing project sources effective entry = %+v, found=%v", entry, found)
585 }
586 }
587
588 func TestLoadNormalizesTOMLPastedCommandLine(t *testing.T) {
589 home := t.TempDir()
590 t.Setenv("HOME", home)
591 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
592 t.Setenv("AppData", filepath.Join(home, "AppData"))
593 t.Chdir(t.TempDir())
594
595 if err := os.WriteFile("reasonix.toml", []byte("[[plugins]]\nname = \"playwright\"\ncommand = \"npx -y @playwright/mcp\"\n"), 0o644); err != nil {
596 t.Fatal(err)
597 }
598 cfg, err := Load()
599 if err != nil {
600 t.Fatal(err)
601 }
602 if len(cfg.Plugins) != 1 {
603 t.Fatalf("plugins = %+v", cfg.Plugins)
604 }
605 if cfg.Plugins[0].Command != "npx" {
606 t.Fatalf("command = %q, want npx", cfg.Plugins[0].Command)
607 }
608 if got := cfg.Plugins[0].Args; len(got) != 2 || got[0] != "-y" || got[1] != "@playwright/mcp" {
609 t.Fatalf("args = %v, want [-y @playwright/mcp]", got)
610 }
611 }
612
613 func TestMergeMCPJSONPrecedence(t *testing.T) {
614 // reasonix.toml already declares "shared" (stdio); .mcp.json offers a colliding
615 // "shared" (http) plus a fresh "extra". reasonix.toml must win on the collision;
616 // "extra" gets appended.
617 cfg := &Config{Plugins: []PluginEntry{
618 {Name: "shared", Command: "local-bin"},
619 }}
620 cfg.mergeMCPJSON([]PluginEntry{
621 {Name: "shared", Type: "http", URL: "https://override.example"},
622 {Name: "extra", Command: "extra-bin"},
623 })
624
625 if len(cfg.Plugins) != 2 {
626 t.Fatalf("plugins = %+v, want 2 (shared kept, extra added)", cfg.Plugins)
627 }
628 if cfg.Plugins[0].Name != "shared" || cfg.Plugins[0].Command != "local-bin" || cfg.Plugins[0].URL != "" {
629 t.Errorf("collision not won by reasonix.toml: %+v", cfg.Plugins[0])
630 }
631 if cfg.Plugins[1].Name != "extra" || cfg.Plugins[1].Command != "extra-bin" {
632 t.Errorf("non-colliding entry not appended: %+v", cfg.Plugins[1])
633 }
634 }
635
636 func TestClearPluginAuthenticationInSourceUsesMCPJSON(t *testing.T) {
637 root := t.TempDir()
638 t.Setenv("HOME", root)
639 t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg"))
640 t.Setenv("AppData", filepath.Join(root, "AppData"))
641 t.Chdir(t.TempDir())
642
643 userPath := UserConfigPath()
644 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
645 t.Fatal(err)
646 }
647 if err := os.WriteFile(userPath, []byte("[[plugins]]\nname = \"global\"\ncommand = \"global-bin\"\n"), 0o644); err != nil {
648 t.Fatal(err)
649 }
650 mcp := `{
651 "mcpServers": {
652 "dida": {
653 "type": "http",
654 "url": "https://mcp.dida365.com/mcp?access_token=abc&workspace=main",
655 "headers": { "Authorization": "Bearer ${DIDA_TOKEN}", "X-Org": "team" },
656 "env": { "DIDA_TOKEN": "${DIDA_TOKEN}", "DEBUG": "1" }
657 }
658 }
659 }`
660 if err := os.WriteFile(mcpJSONFile, []byte(mcp), 0o644); err != nil {
661 t.Fatal(err)
662 }
663
664 updated, changed, source, err := ClearPluginAuthenticationInSource("dida")
665 if err != nil {
666 t.Fatalf("ClearPluginAuthenticationInSource: %v", err)
667 }
668 if !changed {
669 t.Fatal("ClearPluginAuthenticationInSource should report changed")
670 }
671 if source != mcpJSONFile {
672 t.Fatalf("source = %q, want %q", source, mcpJSONFile)
673 }
674 if updated.URL != "https://mcp.dida365.com/mcp?workspace=main" {
675 t.Fatalf("updated URL = %q", updated.URL)
676 }
677
678 userRaw, err := os.ReadFile(userPath)
679 if err != nil {
680 t.Fatal(err)
681 }
682 if strings.Contains(string(userRaw), "dida") {
683 t.Fatalf("user config should not receive .mcp.json server:\n%s", userRaw)
684 }
685 entries, err := loadMCPJSON(mcpJSONFile)
686 if err != nil {
687 t.Fatal(err)
688 }
689 if len(entries) != 1 {
690 t.Fatalf("entries = %+v, want one dida entry", entries)
691 }
692 got := entries[0]
693 if got.URL != "https://mcp.dida365.com/mcp?workspace=main" {
694 t.Fatalf(".mcp.json URL = %q", got.URL)
695 }
696 if _, ok := got.Headers["Authorization"]; ok {
697 t.Fatalf("auth header should be removed: %+v", got.Headers)
698 }
699 if got.Headers["X-Org"] != "team" {
700 t.Fatalf("ordinary header should be preserved: %+v", got.Headers)
701 }
702 if _, ok := got.Env["DIDA_TOKEN"]; ok {
703 t.Fatalf("auth env should be removed: %+v", got.Env)
704 }
705 if got.Env["DEBUG"] != "1" {
706 t.Fatalf("ordinary env should be preserved: %+v", got.Env)
707 }
708 }
709
710 func TestClearPluginAuthenticationInSourcePrefersTOML(t *testing.T) {
711 root := t.TempDir()
712 t.Setenv("HOME", root)
713 t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg"))
714 t.Setenv("AppData", filepath.Join(root, "AppData"))
715 t.Chdir(t.TempDir())
716
717 if err := os.WriteFile("reasonix.toml", []byte(`[[plugins]]
718 name = "dida"
719 type = "http"
720 url = "https://reasonix.example/mcp?access_token=toml"
721 [plugins.headers]
722 Authorization = "Bearer ${TOML_TOKEN}"
723 `), 0o644); err != nil {
724 t.Fatal(err)
725 }
726 mcp := `{ "mcpServers": {
727 "dida": {
728 "type": "http",
729 "url": "https://mcp-json.example/mcp?access_token=json",
730 "headers": { "Authorization": "Bearer ${JSON_TOKEN}" }
731 }
732 } }`
733 if err := os.WriteFile(mcpJSONFile, []byte(mcp), 0o644); err != nil {
734 t.Fatal(err)
735 }
736
737 updated, changed, source, err := ClearPluginAuthenticationInSource("dida")
738 if err != nil {
739 t.Fatalf("ClearPluginAuthenticationInSource: %v", err)
740 }
741 if !changed {
742 t.Fatal("ClearPluginAuthenticationInSource should report changed")
743 }
744 if source != "reasonix.toml" {
745 t.Fatalf("source = %q, want reasonix.toml", source)
746 }
747 if updated.URL != "https://reasonix.example/mcp" {
748 t.Fatalf("updated URL = %q", updated.URL)
749 }
750
751 projectRaw, err := os.ReadFile("reasonix.toml")
752 if err != nil {
753 t.Fatal(err)
754 }
755 if strings.Contains(string(projectRaw), "access_token=toml") || strings.Contains(string(projectRaw), "Authorization") {
756 t.Fatalf("reasonix.toml auth material should be removed:\n%s", projectRaw)
757 }
758 mcpRaw, err := os.ReadFile(mcpJSONFile)
759 if err != nil {
760 t.Fatal(err)
761 }
762 if !strings.Contains(string(mcpRaw), "access_token=json") {
763 t.Fatalf(".mcp.json collision entry should be left untouched:\n%s", mcpRaw)
764 }
765 }
766
767 func TestClearPluginAuthenticationInSourceForRootDoesNotFollowWorkingDirectory(t *testing.T) {
768 home := t.TempDir()
769 t.Setenv("HOME", home)
770 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
771 t.Setenv("AppData", filepath.Join(home, "AppData"))
772 rootA := t.TempDir()
773 rootB := t.TempDir()
774 write := func(root, token string) {
775 t.Helper()
776 raw := fmt.Sprintf(`[[plugins]]
777 name = "dida"
778 type = "http"
779 url = "https://example.test/mcp?access_token=%s&workspace=main"
780 `, token)
781 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(raw), 0o644); err != nil {
782 t.Fatal(err)
783 }
784 }
785 write(rootA, "root-a")
786 write(rootB, "root-b")
787 t.Chdir(rootB)
788
789 updated, changed, source, err := ClearPluginAuthenticationInSourceForRoot(rootA, "dida")
790 if err != nil {
791 t.Fatalf("ClearPluginAuthenticationInSourceForRoot: %v", err)
792 }
793 if !changed || updated.URL != "https://example.test/mcp?workspace=main" {
794 t.Fatalf("updated = %+v, changed = %v", updated, changed)
795 }
796 if want := filepath.Join(rootA, "reasonix.toml"); !samePath(source, want) {
797 t.Fatalf("source = %q, want %q", source, want)
798 }
799 rootBRaw, err := os.ReadFile(filepath.Join(rootB, "reasonix.toml"))
800 if err != nil {
801 t.Fatal(err)
802 }
803 if !strings.Contains(string(rootBRaw), "access_token=root-b") {
804 t.Fatalf("non-target workspace was modified:\n%s", rootBRaw)
805 }
806 }
807
808 func TestLoadLegacyMCP(t *testing.T) {
809 dir := t.TempDir()
810 path := filepath.Join(dir, "config.json")
811 doc := `{
812 "mcpServers": {
813 "github": { "command": "npx", "args": ["-y", "server-github"], "env": { "TOKEN": "x" } },
814 "old": { "command": "foo" },
815 "remote": { "type": "sse", "url": "https://x/sse", "headers": { "Authorization": "Bearer y" } }
816 },
817 "mcpDisabled": ["old"],
818 "projects": { "/some/root": { "shellAllowed": [] } }
819 }`
820 if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
821 t.Fatal(err)
822 }
823
824 got := loadLegacyMCP(path)
825 // "old" is in mcpDisabled and dropped; github + remote remain, name-sorted.
826 if len(got) != 2 {
827 t.Fatalf("got %d entries, want 2: %+v", len(got), got)
828 }
829 if got[0].Name != "github" || got[1].Name != "remote" {
830 t.Fatalf("names = %q, %q; want github, remote", got[0].Name, got[1].Name)
831 }
832 if got[0].Command != "npx" || got[0].Env["TOKEN"] != "x" {
833 t.Errorf("github mapped wrong: %+v", got[0])
834 }
835 if got[1].Type != "sse" || got[1].URL != "https://x/sse" || got[1].Headers["Authorization"] != "Bearer y" {
836 t.Errorf("remote mapped wrong: %+v", got[1])
837 }
838
839 doc = `{
840 "mcp": [
841 "memory=npx -y @modelcontextprotocol/server-memory",
842 "remote=https://x/sse",
843 "stream=streamable+https://x/http",
844 "github=node dupe.js",
845 "off=npx server-off",
846 "uvx run anonymous-server"
847 ],
848 "mcpServers": { "github": { "command": "npx" } },
849 "mcpEnv": { "memory": { "MEMORY_PATH": "/tmp/mem" } },
850 "mcpDisabled": ["off"]
851 }`
852 if err := os.WriteFile(path, []byte(doc), 0o644); err != nil {
853 t.Fatal(err)
854 }
855 got = loadLegacyMCP(path)
856 byName := map[string]PluginEntry{}
857 for _, e := range got {
858 byName[e.Name] = e
859 }
860 if m := byName["memory"]; m.Command != "npx" || m.Env["MEMORY_PATH"] != "/tmp/mem" {
861 t.Errorf("legacy mcp string entry mapped wrong: %+v", m)
862 }
863 if r := byName["remote"]; r.Type != "sse" || r.URL != "https://x/sse" {
864 t.Errorf("plain URL should map to SSE: %+v", r)
865 }
866 if s := byName["stream"]; s.Type != "http" || s.URL != "https://x/http" {
867 t.Errorf("streamable+ URL should map to http: %+v", s)
868 }
869 if g := byName["github"]; g.Command != "npx" || len(g.Args) != 0 {
870 t.Errorf("mcpServers should win the github name collision: %+v", g)
871 }
872 if a := byName["mcp-6"]; a.Command != "uvx" || len(a.Args) != 2 {
873 t.Errorf("anonymous spec should get a synthesized name: %+v", a)
874 }
875 if _, hasOff := byName["off"]; hasOff || len(got) != 5 {
876 t.Errorf("disabled entry should be skipped, got %d: %+v", len(got), got)
877 }
878
879 // Absent, malformed, and empty paths must not error — just yield nil, so a
880 // stale legacy file can never block startup.
881 if got := loadLegacyMCP(filepath.Join(dir, "nope.json")); got != nil {
882 t.Errorf("absent file: got %+v, want nil", got)
883 }
884 if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
885 t.Fatal(err)
886 }
887 if got := loadLegacyMCP(path); got != nil {
888 t.Errorf("malformed file: got %+v, want nil", got)
889 }
890 if got := loadLegacyMCP(""); got != nil {
891 t.Errorf("empty path: got %+v, want nil", got)
892 }
893 }
894
895 func TestRemovePluginFromSourcesForRootRemovesEveryWritableDeclaration(t *testing.T) {
896 _, userConfig, _ := legacyHome(t)
897 root := t.TempDir()
898 if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil {
899 t.Fatal(err)
900 }
901 for _, path := range []string{userConfig, filepath.Join(root, "reasonix.toml")} {
902 if err := os.WriteFile(path, []byte(`
903 [[plugins]]
904 name = "duplicate"
905 command = "duplicate-mcp"
906 `), 0o644); err != nil {
907 t.Fatal(err)
908 }
909 }
910 mcpPath := filepath.Join(root, mcpJSONFile)
911 if err := os.WriteFile(mcpPath, []byte(`{
912 "mcpServers": {
913 "duplicate": { "command": "duplicate-json" },
914 "keep": { "command": "keep-json" }
915 }
916 }`), 0o644); err != nil {
917 t.Fatal(err)
918 }
919
920 removed, err := RemovePluginFromSourcesForRoot(root, "duplicate")
921 if err != nil {
922 t.Fatalf("RemovePluginFromSourcesForRoot: %v", err)
923 }
924 if !removed {
925 t.Fatal("RemovePluginFromSourcesForRoot reported no removal")
926 }
927 for _, path := range []string{userConfig, filepath.Join(root, "reasonix.toml")} {
928 for _, p := range LoadForEdit(path).Plugins {
929 if p.Name == "duplicate" {
930 t.Fatalf("duplicate MCP survived in %s: %+v", path, p)
931 }
932 }
933 }
934 if _, found, err := LoadMCPJSONPlugin(mcpPath, "duplicate"); err != nil || found {
935 t.Fatalf("duplicate .mcp.json entry survived: found=%v err=%v", found, err)
936 }
937 if _, found, err := LoadMCPJSONPlugin(mcpPath, "keep"); err != nil || !found {
938 t.Fatalf("unrelated .mcp.json entry was lost: found=%v err=%v", found, err)
939 }
940 }
941
942 func TestRemovePluginFromSourcesForRootPreflightsEverySource(t *testing.T) {
943 _, userConfig, _ := legacyHome(t)
944 root := t.TempDir()
945 if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil {
946 t.Fatal(err)
947 }
948 const original = `[[plugins]]
949 name = "duplicate"
950 command = "duplicate-mcp"
951 `
952 if err := os.WriteFile(userConfig, []byte(original), 0o600); err != nil {
953 t.Fatal(err)
954 }
955 if err := os.WriteFile(filepath.Join(root, mcpJSONFile), []byte(`{"mcpServers":`), 0o644); err != nil {
956 t.Fatal(err)
957 }
958
959 if removed, err := RemovePluginFromSourcesForRoot(root, "duplicate"); err == nil || removed {
960 t.Fatalf("RemovePluginFromSourcesForRoot = (%v, %v), want false and malformed .mcp.json error", removed, err)
961 }
962 got, err := os.ReadFile(userConfig)
963 if err != nil {
964 t.Fatal(err)
965 }
966 if string(got) != original {
967 t.Fatalf("user config changed before every source was validated:\n%s", got)
968 }
969 }
970
971 func TestApplyConfigSourceEditsRollsBackEarlierWrites(t *testing.T) {
972 dir := t.TempDir()
973 first := filepath.Join(dir, "first.toml")
974 second := filepath.Join(dir, "second.toml")
975 for _, path := range []string{first, second} {
976 if err := os.WriteFile(path, []byte("before\n"), 0o600); err != nil {
977 t.Fatal(err)
978 }
979 }
980 firstEdit, err := newConfigSourceEdit(first, func() error {
981 return os.WriteFile(first, []byte("after\n"), 0o600)
982 })
983 if err != nil {
984 t.Fatal(err)
985 }
986 secondEdit, err := newConfigSourceEdit(second, func() error {
987 return errors.New("publish failed")
988 })
989 if err != nil {
990 t.Fatal(err)
991 }
992 if err := applyConfigSourceEdits([]configSourceEdit{firstEdit, secondEdit}); err == nil {
993 t.Fatal("applyConfigSourceEdits unexpectedly succeeded")
994 }
995 for _, path := range []string{first, second} {
996 got, err := os.ReadFile(path)
997 if err != nil {
998 t.Fatal(err)
999 }
1000 if string(got) != "before\n" {
1001 t.Fatalf("%s was not rolled back: %q", path, got)
1002 }
1003 }
1004 }
1005
1006 func TestApplyConfigSourceEditsRollbackPreservesSymlink(t *testing.T) {
1007 dir := t.TempDir()
1008 target := filepath.Join(dir, "target.toml")
1009 link := filepath.Join(dir, "config.toml")
1010 second := filepath.Join(dir, "second.toml")
1011 for _, path := range []string{target, second} {
1012 if err := os.WriteFile(path, []byte("before\n"), 0o600); err != nil {
1013 t.Fatal(err)
1014 }
1015 }
1016 if err := os.Symlink(target, link); err != nil {
1017 t.Skipf("symlinks are unavailable: %v", err)
1018 }
1019
1020 firstEdit, err := newConfigSourceEdit(link, func() error {
1021 return atomicWriteToConfigFile(link, "after\n", 0o600)
1022 })
1023 if err != nil {
1024 t.Fatal(err)
1025 }
1026 secondEdit, err := newConfigSourceEdit(second, func() error {
1027 return errors.New("publish failed")
1028 })
1029 if err != nil {
1030 t.Fatal(err)
1031 }
1032 if err := applyConfigSourceEdits([]configSourceEdit{firstEdit, secondEdit}); err == nil {
1033 t.Fatal("applyConfigSourceEdits unexpectedly succeeded")
1034 }
1035
1036 info, err := os.Lstat(link)
1037 if err != nil {
1038 t.Fatal(err)
1039 }
1040 if info.Mode()&os.ModeSymlink == 0 {
1041 t.Fatal("rollback replaced the config symlink")
1042 }
1043 got, err := os.ReadFile(target)
1044 if err != nil {
1045 t.Fatal(err)
1046 }
1047 if string(got) != "before\n" {
1048 t.Fatalf("rollback target = %q, want original content", got)
1049 }
1050 }
1051
1052 func TestMCPJSONInternalSymlinkIsPreserved(t *testing.T) {
1053 root := t.TempDir()
1054 target := filepath.Join(root, "shared-mcp.json")
1055 link := filepath.Join(root, mcpJSONFile)
1056 if err := os.WriteFile(target, []byte("{\"mcpServers\":{}}\n"), 0o644); err != nil {
1057 t.Fatal(err)
1058 }
1059 if err := os.Symlink(target, link); err != nil {
1060 t.Skipf("symlinks are unavailable: %v", err)
1061 }
1062
1063 if _, err := UpsertMCPJSONPlugin(link, PluginEntry{Name: "internal", Command: "internal-mcp"}); err != nil {
1064 t.Fatal(err)
1065 }
1066 info, err := os.Lstat(link)
1067 if err != nil {
1068 t.Fatal(err)
1069 }
1070 if info.Mode()&os.ModeSymlink == 0 {
1071 t.Fatal("UpsertMCPJSONPlugin replaced the project symlink")
1072 }
1073 entry, found, err := LoadMCPJSONPlugin(link, "internal")
1074 if err != nil || !found || entry.Command != "internal-mcp" {
1075 t.Fatalf("LoadMCPJSONPlugin = (%+v, %v, %v)", entry, found, err)
1076 }
1077 }
1078
1079 func TestMCPJSONRejectsExternalAndBrokenSymlinks(t *testing.T) {
1080 for _, tt := range []struct {
1081 name string
1082 target func(root string) string
1083 }{
1084 {
1085 name: "external",
1086 target: func(root string) string {
1087 external := filepath.Join(t.TempDir(), "external.json")
1088 if err := os.WriteFile(external, []byte("{\"mcpServers\":{}}\n"), 0o600); err != nil {
1089 t.Fatal(err)
1090 }
1091 return external
1092 },
1093 },
1094 {
1095 name: "broken",
1096 target: func(root string) string {
1097 return filepath.Join(root, "missing.json")
1098 },
1099 },
1100 } {
1101 t.Run(tt.name, func(t *testing.T) {
1102 root := t.TempDir()
1103 link := filepath.Join(root, mcpJSONFile)
1104 target := tt.target(root)
1105 if err := os.Symlink(target, link); err != nil {
1106 t.Skipf("symlinks are unavailable: %v", err)
1107 }
1108 if _, err := loadMCPJSON(link); err == nil {
1109 t.Fatal("loadMCPJSON accepted unsafe project symlink")
1110 }
1111 if _, err := UpsertMCPJSONPlugin(link, PluginEntry{Name: "unsafe", Command: "unsafe-mcp"}); err == nil {
1112 t.Fatal("UpsertMCPJSONPlugin accepted unsafe project symlink")
1113 }
1114 if _, err := RemoveMCPJSONPlugin(link, "unsafe"); err == nil {
1115 t.Fatal("RemoveMCPJSONPlugin accepted unsafe project symlink")
1116 }
1117 info, err := os.Lstat(link)
1118 if err != nil {
1119 t.Fatal(err)
1120 }
1121 if info.Mode()&os.ModeSymlink == 0 {
1122 t.Fatal("failed MCP operation replaced unsafe symlink")
1123 }
1124 })
1125 }
1126 }
1127
1128 func TestClearPluginAuthenticationHonorsMCPJSONFileLock(t *testing.T) {
1129 root := t.TempDir()
1130 t.Setenv("REASONIX_HOME", filepath.Join(root, "home"))
1131 mcpPath := filepath.Join(root, mcpJSONFile)
1132 if err := os.WriteFile(mcpPath, []byte(`{
1133 "mcpServers": {
1134 "remote": {
1135 "type": "http",
1136 "url": "https://example.com/mcp?token=secret",
1137 "headers": {"Authorization": "Bearer secret"}
1138 }
1139 }
1140 }
1141 `), 0o644); err != nil {
1142 t.Fatal(err)
1143 }
1144 release, err := acquireConfigFileEditLockWithTimeout(mcpPath, time.Second)
1145 if err != nil {
1146 t.Fatal(err)
1147 }
1148 defer release()
1149
1150 previousTimeout := configEditLockTimeout
1151 configEditLockTimeout = 30 * time.Millisecond
1152 t.Cleanup(func() { configEditLockTimeout = previousTimeout })
1153 if _, _, _, err := ClearPluginAuthenticationInSourceForRoot(root, "remote"); err == nil {
1154 t.Fatal("clear authentication ignored the project MCP file lock")
1155 }
1156 raw, err := os.ReadFile(mcpPath)
1157 if err != nil {
1158 t.Fatal(err)
1159 }
1160 if !strings.Contains(string(raw), "Bearer secret") {
1161 t.Fatal("authentication changed after lock acquisition failed")
1162 }
1163 }
1164
1165 func TestInstallUserPluginForRootRestoresConfigWhenActivationFails(t *testing.T) {
1166 home := t.TempDir()
1167 t.Setenv("REASONIX_HOME", home)
1168 workspace := t.TempDir()
1169
1170 cfg := Default()
1171 cfg.Agent.Temperature = 0.42
1172 if err := cfg.UpsertPlugin(PluginEntry{
1173 Name: "docs",
1174 Command: "existing-docs",
1175 Source: MCPSourceUserConfig,
1176 }); err != nil {
1177 t.Fatal(err)
1178 }
1179 if err := cfg.SaveTo(UserConfigPath()); err != nil {
1180 t.Fatal(err)
1181 }
1182 if err := os.MkdirAll(MCPActivationPath(home), 0o700); err != nil {
1183 t.Fatal(err)
1184 }
1185
1186 _, err := InstallUserPluginForRoot(workspace, PluginEntry{
1187 Name: "docs",
1188 Command: "replacement-docs",
1189 }, true)
1190 if err == nil {
1191 t.Fatal("install succeeded with an unreadable activation path")
1192 }
1193
1194 got, loadErr := LoadForEditReadOnlyStrict(UserConfigPath())
1195 if loadErr != nil {
1196 t.Fatal(loadErr)
1197 }
1198 entry, found := pluginEntryByName(got.Plugins, "docs")
1199 if !found || entry.Command != "existing-docs" {
1200 t.Fatalf("rolled-back plugin = %+v, found=%v", entry, found)
1201 }
1202 if got.Agent.Temperature != 0.42 {
1203 t.Fatalf("rollback lost unrelated config: temperature = %v", got.Agent.Temperature)
1204 }
1205 }
1206
1207 func TestRemoveEffectivePluginLocksAllCompetingSources(t *testing.T) {
1208 root := t.TempDir()
1209 t.Setenv("REASONIX_HOME", filepath.Join(root, "home"))
1210 userPath := UserConfigPath()
1211 cfg := Default()
1212 if err := cfg.UpsertPlugin(PluginEntry{Name: "shared", Command: "user-mcp"}); err != nil {
1213 t.Fatal(err)
1214 }
1215 if err := cfg.SaveTo(userPath); err != nil {
1216 t.Fatal(err)
1217 }
1218
1219 // The project file does not currently define "shared", but it can become the
1220 // higher-priority owner at any time. Holding its cross-process lock must stop
1221 // effective-source selection before the user declaration is removed.
1222 projectPath := filepath.Join(root, "reasonix.toml")
1223 if err := os.WriteFile(projectPath, []byte("# project config\n"), 0o644); err != nil {
1224 t.Fatal(err)
1225 }
1226 release, err := acquireConfigFileEditLockWithTimeout(projectPath, time.Second)
1227 if err != nil {
1228 t.Fatalf("hold project config lock: %v", err)
1229 }
1230 defer release()
1231
1232 previousTimeout := configEditLockTimeout
1233 configEditLockTimeout = 30 * time.Millisecond
1234 t.Cleanup(func() { configEditLockTimeout = previousTimeout })
1235 if _, _, _, err := RemovePluginFromEffectiveSourceForRoot(root, "shared"); err == nil {
1236 t.Fatal("effective-source removal ignored a competing project config lock")
1237 }
1238
1239 after, err := LoadForEditReadOnlyStrict(userPath)
1240 if err != nil {
1241 t.Fatal(err)
1242 }
1243 if _, ok := pluginEntryByName(after.Plugins, "shared"); !ok {
1244 t.Fatal("effective-source removal changed user config after lock acquisition failed")
1245 }
1246 }
1247
1248 func TestRemovePluginFromSourcesRejectsBrokenConfigSymlink(t *testing.T) {
1249 root := t.TempDir()
1250 t.Setenv("REASONIX_HOME", filepath.Join(root, "home"))
1251 link := filepath.Join(root, "reasonix.toml")
1252 if err := os.Symlink(filepath.Join(root, "missing.toml"), link); err != nil {
1253 t.Skipf("symlinks are unavailable: %v", err)
1254 }
1255 if _, err := RemovePluginFromSourcesForRoot(root, "missing"); err == nil {
1256 t.Fatal("multi-source removal silently skipped a broken config symlink")
1257 }
1258 info, err := os.Lstat(link)
1259 if err != nil {
1260 t.Fatal(err)
1261 }
1262 if info.Mode()&os.ModeSymlink == 0 {
1263 t.Fatal("multi-source removal replaced the broken config symlink")
1264 }
1265 }
1266
1267 func TestUpsertPluginInProjectSourceRequiresProjectFileLock(t *testing.T) {
1268 root := t.TempDir()
1269 path := filepath.Join(root, "reasonix.toml")
1270 const original = "# project config\n"
1271 if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
1272 t.Fatal(err)
1273 }
1274 release, err := acquireConfigFileEditLockWithTimeout(path, time.Second)
1275 if err != nil {
1276 t.Fatalf("hold project config lock: %v", err)
1277 }
1278 defer release()
1279
1280 previousTimeout := configEditLockTimeout
1281 configEditLockTimeout = 30 * time.Millisecond
1282 t.Cleanup(func() { configEditLockTimeout = previousTimeout })
1283
1284 _, err = UpsertPluginInSourceForRoot(root, PluginEntry{
1285 Name: "locked",
1286 Command: "locked-mcp",
1287 Source: MCPSourceProjectConfig,
1288 })
1289 if err == nil {
1290 t.Fatal("project MCP update ignored the project config file lock")
1291 }
1292 got, readErr := os.ReadFile(path)
1293 if readErr != nil {
1294 t.Fatal(readErr)
1295 }
1296 if string(got) != original {
1297 t.Fatalf("failed locked update changed project config:\n%s", got)
1298 }
1299 }
1300
1300 lines GO