返回 DeepSeek-Reasonix
render_test.go
根目录 / internal / config / render_test.go
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "reflect"
7 "runtime"
8 "strconv"
9 "strings"
10 "testing"
11
12 "github.com/BurntSushi/toml"
13 )
14
15 func isolateUserConfigHome(t *testing.T) string {
16 t.Helper()
17 home := t.TempDir()
18 t.Setenv("HOME", home)
19 for _, key := range []string{"REASONIX_HOME", "REASONIX_STATE_HOME", "REASONIX_CACHE_HOME"} {
20 t.Setenv(key, "")
21 if err := os.Unsetenv(key); err != nil {
22 t.Fatalf("unset %s: %v", key, err)
23 }
24 }
25 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
26 t.Setenv("USERPROFILE", home)
27 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
28 t.Setenv("AppData", filepath.Join(home, "AppData", "Roaming"))
29 return home
30 }
31
32 // setRuntimeGOOS overrides the package-level runtimeGOOS for one test. The
33 // t.Setenv call is a guard: it panics if the test also uses t.Parallel, which
34 // would otherwise race on the shared global.
35 func setRuntimeGOOS(t *testing.T, goos string) {
36 t.Helper()
37 t.Setenv("REASONIX_TEST_GOOS", goos)
38 old := runtimeGOOS
39 runtimeGOOS = goos
40 t.Cleanup(func() { runtimeGOOS = old })
41 }
42
43 func expectedDefaultReasonixHome(home string) string {
44 if runtime.GOOS == "windows" {
45 return filepath.Join(home, "AppData", "Roaming", "reasonix")
46 }
47 return filepath.Join(home, ".reasonix")
48 }
49
50 func TestUserConfigDisplayPathCollapsesHome(t *testing.T) {
51 home := isolateUserConfigHome(t)
52 got := userConfigDisplayPath()
53 if !strings.HasPrefix(got, "~/") {
54 t.Fatalf("display path = %q, want ~/ prefix", got)
55 }
56 if !strings.HasSuffix(got, "reasonix/config.toml") {
57 t.Fatalf("display path = %q, want reasonix/config.toml suffix", got)
58 }
59 if strings.Contains(got, home) {
60 t.Fatalf("display path %q must not embed the absolute home", got)
61 }
62 }
63
64 func TestUserConfigPathUsesReasonixHome(t *testing.T) {
65 home := isolateUserConfigHome(t)
66 want := filepath.Join(expectedDefaultReasonixHome(home), "config.toml")
67 if got := UserConfigPath(); filepath.Clean(got) != filepath.Clean(want) {
68 t.Fatalf("UserConfigPath() = %q, want %q", got, want)
69 }
70 }
71
72 func TestReasonixManagedConfigPathsAreConfigFilesOnly(t *testing.T) {
73 home := isolateUserConfigHome(t)
74 setRuntimeGOOS(t, "windows")
75 oldConfigDir := osUserConfigDir
76 osUserConfigDir = func() string { return filepath.Join(home, "AppData", "Roaming") }
77 t.Cleanup(func() { osUserConfigDir = oldConfigDir })
78
79 paths := ReasonixManagedConfigPaths()
80 for _, want := range []string{
81 filepath.Join(home, "AppData", "Roaming", "reasonix", "config.toml"),
82 filepath.Join(home, ".reasonix", "config.json"),
83 } {
84 found := false
85 for _, got := range paths {
86 if samePath(got, want) {
87 found = true
88 break
89 }
90 }
91 if !found {
92 t.Fatalf("managed config paths = %v, want %s", paths, want)
93 }
94 }
95 // The escape hatch is file-level by contract: no directories, and none of
96 // the sensitive Reasonix-home siblings (credentials, hooks, skills,
97 // sessions) may ride along.
98 for _, got := range paths {
99 if base := filepath.Base(got); base != "config.toml" && base != "config.json" {
100 t.Fatalf("managed config path %q is not a known config file (paths must be files, not directories): %v", got, paths)
101 }
102 for _, forbidden := range []string{
103 home,
104 ReasonixHomeDir(),
105 UserCredentialsPath(),
106 filepath.Join(ReasonixHomeDir(), "settings.json"),
107 filepath.Join(ReasonixHomeDir(), "skills"),
108 filepath.Join(ReasonixHomeDir(), "sessions"),
109 } {
110 if samePath(got, forbidden) {
111 t.Fatalf("managed config paths must not include %q: %v", forbidden, paths)
112 }
113 }
114 }
115 }
116
117 func TestUserConfigPathHonorsReasonixHome(t *testing.T) {
118 home := isolateUserConfigHome(t)
119 custom := filepath.Join(home, "custom-home")
120 t.Setenv("REASONIX_HOME", custom)
121
122 want := filepath.Join(custom, "config.toml")
123 if got := UserConfigPath(); filepath.Clean(got) != filepath.Clean(want) {
124 t.Fatalf("UserConfigPath() = %q, want %q", got, want)
125 }
126 }
127
128 func TestLoadForRootUsesWindowsHomeFallbackWhenConfigDirUnavailable(t *testing.T) {
129 home := t.TempDir()
130 project := t.TempDir()
131
132 oldGOOS := runtimeGOOS
133 oldConfigDir := osUserConfigDir
134 oldHomeDir := osUserHomeDir
135 runtimeGOOS = "windows"
136 osUserConfigDir = func() string { return "" }
137 osUserHomeDir = func() (string, error) { return home, nil }
138 t.Cleanup(func() {
139 runtimeGOOS = oldGOOS
140 osUserConfigDir = oldConfigDir
141 osUserHomeDir = oldHomeDir
142 })
143
144 t.Setenv("REASONIX_HOME", "")
145
146 configPath := filepath.Join(home, "AppData", "Roaming", "reasonix", "config.toml")
147 if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
148 t.Fatal(err)
149 }
150 if err := os.WriteFile(configPath, []byte("default_model = \"custom/from-home\"\n"), 0o600); err != nil {
151 t.Fatal(err)
152 }
153
154 cfg, err := LoadForRoot(project)
155 if err != nil {
156 t.Fatalf("LoadForRoot() error = %v", err)
157 }
158 if cfg.DefaultModel != "custom/from-home" {
159 t.Fatalf("DefaultModel = %q, want %q", cfg.DefaultModel, "custom/from-home")
160 }
161 }
162
163 func TestRenderTOMLHeaderShowsResolvedConfigPath(t *testing.T) {
164 isolateUserConfigHome(t)
165 out := RenderTOML(Default())
166 want := "> " + userConfigDisplayPath() + " > built-in defaults."
167 if !strings.Contains(out, want) {
168 t.Fatalf("rendered header missing resolved config path %q", want)
169 }
170 }
171
172 func TestWriteRootsForRootExcludesUserConfigDirByDefault(t *testing.T) {
173 isolateUserConfigHome(t)
174 project := t.TempDir()
175 cfg := Default()
176
177 roots := cfg.WriteRootsForRoot(project)
178 want := filepath.Clean(filepath.Dir(UserConfigPath()))
179 for _, root := range roots {
180 if filepath.Clean(root) == want {
181 t.Fatalf("WriteRootsForRoot() = %v, must not include user config dir %q by default", roots, want)
182 }
183 }
184 if got := filepath.Clean(roots[0]); got != filepath.Clean(project) {
185 t.Fatalf("first write root = %q, want project %q", got, project)
186 }
187 }
188
189 // TestRenderTOMLRoundTrips ensures the annotated TOML we emit parses back into
190 // an equivalent config — i.e. the wizard never writes a file it can't read.
191 func TestRenderTOMLRoundTrips(t *testing.T) {
192 orig := Default()
193 orig.Providers = append(orig.Providers, legacyMimoCustomProvider("mimo-pro"))
194 orig.DefaultModel = "mimo-pro"
195 orig.Language = "zh"
196 orig.UI.Theme = "light"
197 orig.UI.ThemeStyle = "glacier"
198 orig.UI.ShortcutLayout = "desktop"
199 orig.UI.CursorShape = "bar"
200 orig.Desktop.Language = "en"
201 orig.Desktop.LayoutStyle = "workbench"
202 orig.Desktop.Theme = "dark"
203 orig.Desktop.ThemeStyle = "graphite"
204 orig.Desktop.TerminalTheme = "light"
205 orig.Desktop.CloseBehavior = "background"
206 orig.Desktop.DisplayMode = "compact"
207 orig.Desktop.StatusBarStyle = "text"
208 orig.Desktop.StatusBarItems = []string{"model", "balance", "cache"}
209 orig.Desktop.DefaultToolApprovalMode = "auto"
210 orig.Desktop.CheckUpdates = boolPtr(false)
211 orig.Desktop.UpdateChannel = "preview"
212 orig.Desktop.Telemetry = boolPtr(false)
213 orig.Notifications.Enabled = true
214 orig.Notifications.TurnDone = true
215 orig.Notifications.ApprovalRequest = true
216 orig.Notifications.AskRequest = true
217 orig.Agent.RecoveryModel = "mimo-pro"
218 orig.Agent.RecoveryTemperature = 0.15
219 orig.Agent.ReasoningLanguage = "zh"
220 orig.Agent.ToolResultSnipRatio = 0.65
221 orig.Agent.SubagentModel = "mimo-pro"
222 orig.Agent.SubagentModels = map[string]string{"review": "deepseek-pro"}
223 orig.Agent.MaxSubagentDepth = 3
224 orig.Agent.Keep = []string{"errors", "user_marked"}
225 orig.Agent.RecentKeep = 4
226 orig.Tools.BashTimeoutSeconds = intPtr(900)
227 orig.Tools.BackgroundJobs.StalledWarningSeconds = intPtr(30)
228 orig.Tools.Shell.Prefer = "bash"
229 orig.Tools.Shell.Path = "/usr/local/bin/bash"
230 orig.Permissions = PermissionsConfig{
231 Mode: "deny",
232 Deny: []string{"Bash(rm -rf*)"},
233 Allow: []string{"Bash(go test:*)", "read_file"},
234 AllowDynamicBash: true,
235 }
236 orig.Network = NetworkConfig{
237 ProxyMode: "custom",
238 NoProxy: "localhost,127.0.0.1",
239 Proxy: NetworkProxyConfig{
240 Type: "socks5",
241 Server: "127.0.0.1",
242 Port: 7890,
243 Username: "user",
244 Password: "${REASONIX_PROXY_PASSWORD}",
245 },
246 }
247 orig.Environment.Enabled = boolPtr(false)
248 orig.Environment.Tools = map[string]string{"go": "/opt/homebrew/bin/go", "python3": "~/.pyenv/shims/python3"}
249 orig.Skills.Paths = []string{"~/my-skills", "../shared/skills"}
250 orig.Skills.ExcludedPaths = []string{"~/.agents/skills"}
251 orig.Skills.DisabledSkills = []string{"review", "explore"}
252 orig.Skills.MaxDepth = 2
253 orig.Bot.ToolApprovalMode = "auto"
254 orig.Bot.Control = BotControlConfig{Enabled: true, Addr: "127.0.0.1:39001", TokenEnv: "BOT_CONTROL_TOKEN"}
255 orig.Bot.Feishu.OutboundMediaRoots = []string{"/tmp/reasonix-media", "/srv/shots"}
256 orig.Bot.Routes = []BotRouteConfig{{
257 ConnectionID: "feishu-lark",
258 ChatType: "group",
259 ChatID: "oc_group",
260 Model: "deepseek-pro",
261 ToolApprovalMode: "ask",
262 WorkspaceRoot: "/tmp/reasonix-route",
263 }}
264 orig.Bot.DesktopWatchers = []BotDesktopWatcherConfig{{
265 Platform: "feishu",
266 ConnectionID: "feishu-lark",
267 Domain: "lark",
268 ChatType: "dm",
269 ChatID: "oc_watcher",
270 }}
271 orig.Bot.Connections = []BotConnectionConfig{{
272 ID: "feishu-lark",
273 Provider: "feishu",
274 Domain: "lark",
275 Label: "Lark",
276 Enabled: true,
277 Status: "connected",
278 Model: "deepseek-pro",
279 ToolApprovalMode: "yolo",
280 WorkspaceRoot: "/tmp/reasonix-bot",
281 Credential: BotConnectionCredential{AppID: "cli_lark", AppSecretEnv: "LARK_BOT_APP_SECRET"},
282 SessionMappings: []BotConnectionSessionMapping{{
283 RemoteID: "ou_123",
284 SessionID: "topic:topic_bot",
285 Scope: "project",
286 WorkspaceRoot: "/tmp/reasonix-bot",
287 UpdatedAt: "2026-06-11T00:00:00Z",
288 }},
289 }}
290 orig.LSP = LSPConfig{
291 Enabled: true,
292 Servers: map[string]LSPServer{
293 "lua": {
294 Command: "lua-language-server",
295 Args: []string{"--stdio"},
296 Env: map[string]string{"LUA_PATH": "./?.lua"},
297 LanguageID: "lua",
298 Extensions: []string{".lua", ".script", ".gui_script"},
299 InstallHint: "install lua-language-server",
300 },
301 },
302 }
303 orig.Plugins = []PluginEntry{
304 {Name: "example", Command: "reasonix-plugin-example"},
305 {Name: "stripe", Type: "http", URL: "https://mcp.stripe.com", Headers: map[string]string{"Authorization": "Bearer x"}, AutoStart: boolPtr(false), Tier: "background"},
306 }
307 mm, _ := orig.Provider("mimo-pro")
308 mm.BaseURL = "http://localhost:8000/v1"
309 mm.ChatURL = "http://localhost:8000/v1/chat/completions"
310 mm.ModelsURL = "http://localhost:8000/v1/models"
311 mm.ReasoningProtocol = "openai"
312 mm.PresetID = "mimo-api"
313 mm.PresetVersion = ProviderPresetVersion
314 ds, _ := orig.Provider("deepseek-flash")
315 ds.Effort = "max"
316
317 rendered := RenderTOML(orig)
318
319 var got Config
320 if _, err := toml.Decode(rendered, &got); err != nil {
321 t.Fatalf("rendered TOML does not parse: %v\n---\n%s", err, rendered)
322 }
323
324 if got.DefaultModel != "mimo-pro" {
325 t.Errorf("default_model = %q, want mimo-pro", got.DefaultModel)
326 }
327 if got.ConfigVersion != 5 {
328 t.Errorf("config_version = %d, want 5", got.ConfigVersion)
329 }
330 if got.Language != "zh" {
331 t.Errorf("language = %q, want zh", got.Language)
332 }
333 if got.UI.Theme != "light" {
334 t.Errorf("ui.theme = %q, want light", got.UI.Theme)
335 }
336 if got.UI.ThemeStyle != "glacier" {
337 t.Errorf("ui.theme_style = %q, want glacier", got.UI.ThemeStyle)
338 }
339 if got.UI.ShortcutLayout != "desktop" {
340 t.Errorf("ui.shortcut_layout = %q, want desktop", got.UI.ShortcutLayout)
341 }
342 if got.UICursorShape() != "bar" {
343 t.Errorf("ui.cursor_shape = %q, want bar", got.UICursorShape())
344 }
345 if !got.UI.ShowTurnUsage {
346 t.Error("ui.show_turn_usage = false, want true")
347 }
348 if got.Desktop.Language != "en" {
349 t.Errorf("desktop.language = %q, want en", got.Desktop.Language)
350 }
351 if got.Desktop.LayoutStyle != "workbench" {
352 t.Errorf("desktop.layout_style = %q, want workbench", got.Desktop.LayoutStyle)
353 }
354 if got.Desktop.Theme != "dark" {
355 t.Errorf("desktop.theme = %q, want dark", got.Desktop.Theme)
356 }
357 if got.Desktop.ThemeStyle != "graphite" {
358 t.Errorf("desktop.theme_style = %q, want graphite", got.Desktop.ThemeStyle)
359 }
360 if got.Desktop.TerminalTheme != "light" {
361 t.Errorf("desktop.terminal_theme = %q, want light", got.Desktop.TerminalTheme)
362 }
363 if got.Desktop.CloseBehavior != "background" {
364 t.Errorf("desktop.close_behavior = %q, want background", got.Desktop.CloseBehavior)
365 }
366 if got.DesktopDisplayMode() != "compact" {
367 t.Errorf("desktop.display_mode = %q, want compact", got.DesktopDisplayMode())
368 }
369 if got.Desktop.StatusBarStyle != "text" {
370 t.Errorf("desktop.status_bar_style = %q, want text", got.Desktop.StatusBarStyle)
371 }
372 if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.Desktop.StatusBarItems, want) {
373 t.Errorf("desktop.status_bar_items = %v, want %v", got.Desktop.StatusBarItems, want)
374 }
375 if got.DesktopDefaultToolApprovalMode() != "auto" {
376 t.Errorf("desktop.default_tool_approval_mode = %q, want auto", got.DesktopDefaultToolApprovalMode())
377 }
378 if got.Desktop.CheckUpdates == nil || *got.Desktop.CheckUpdates {
379 t.Errorf("desktop.check_updates = %+v, want false", got.Desktop.CheckUpdates)
380 }
381 if got.DesktopUpdateChannel() != "stable" {
382 t.Errorf("desktop.update_channel = %q, want stable", got.DesktopUpdateChannel())
383 }
384 if got.Agent.RecoveryModel != "mimo-pro" || got.Agent.RecoveryTemperature != 0 {
385 t.Errorf("agent recovery settings not preserved: %+v", got.Agent)
386 }
387 if !got.Notifications.Enabled || !got.Notifications.TurnDone || !got.Notifications.ApprovalRequest || !got.Notifications.AskRequest {
388 t.Errorf("notifications not preserved: %+v", got.Notifications)
389 }
390 if got.Agent.MaxSteps != orig.Agent.MaxSteps {
391 t.Errorf("max_steps = %d, want %d", got.Agent.MaxSteps, orig.Agent.MaxSteps)
392 }
393 if got.Agent.PlannerMaxSteps != orig.Agent.PlannerMaxSteps {
394 t.Errorf("planner_max_steps = %d, want %d", got.Agent.PlannerMaxSteps, orig.Agent.PlannerMaxSteps)
395 }
396 if len(got.Bot.Connections) != 1 || got.Bot.Connections[0].Model != "deepseek-pro" || got.Bot.Connections[0].WorkspaceRoot != "/tmp/reasonix-bot" {
397 t.Errorf("bot connection not preserved: %+v", got.Bot.Connections)
398 }
399 if got.Bot.ToolApprovalMode != "auto" || got.Bot.Connections[0].ToolApprovalMode != "yolo" {
400 t.Errorf("bot tool approval mode not preserved: bot=%q connection=%q", got.Bot.ToolApprovalMode, got.Bot.Connections[0].ToolApprovalMode)
401 }
402 if !got.Bot.Control.Enabled || got.Bot.Control.Addr != "127.0.0.1:39001" || got.Bot.Control.TokenEnv != "BOT_CONTROL_TOKEN" {
403 t.Errorf("bot control not preserved: %+v", got.Bot.Control)
404 }
405 if len(got.Bot.Feishu.OutboundMediaRoots) != 2 || got.Bot.Feishu.OutboundMediaRoots[0] != "/tmp/reasonix-media" {
406 t.Errorf("feishu outbound_media_roots not preserved: %+v", got.Bot.Feishu.OutboundMediaRoots)
407 }
408 if len(got.Bot.Routes) != 1 || got.Bot.Routes[0].WorkspaceRoot != "/tmp/reasonix-route" || got.Bot.Routes[0].ChatID != "oc_group" {
409 t.Errorf("bot routes not preserved: %+v", got.Bot.Routes)
410 }
411 if len(got.Bot.DesktopWatchers) != 1 || got.Bot.DesktopWatchers[0].ChatID != "oc_watcher" || got.Bot.DesktopWatchers[0].Platform != "feishu" || got.Bot.DesktopWatchers[0].Domain != "lark" {
412 t.Errorf("bot desktop watchers not preserved: %+v", got.Bot.DesktopWatchers)
413 }
414 if len(got.Bot.Connections[0].SessionMappings) != 1 || got.Bot.Connections[0].SessionMappings[0].Scope != "project" || got.Bot.Connections[0].SessionMappings[0].WorkspaceRoot != "/tmp/reasonix-bot" {
415 t.Errorf("bot session mapping scope not preserved: %+v", got.Bot.Connections[0].SessionMappings)
416 }
417 if got.Agent.Temperature != orig.Agent.Temperature {
418 t.Errorf("temperature = %v, want %v", got.Agent.Temperature, orig.Agent.Temperature)
419 }
420 if got.Agent.ReasoningLanguage != "zh" {
421 t.Errorf("reasoning_language = %q, want zh", got.Agent.ReasoningLanguage)
422 }
423 if got.Agent.SoftCompactRatio != orig.Agent.SoftCompactRatio {
424 t.Errorf("soft_compact_ratio = %v, want %v", got.Agent.SoftCompactRatio, orig.Agent.SoftCompactRatio)
425 }
426 if got.Agent.ToolResultSnipRatio != orig.Agent.ToolResultSnipRatio {
427 t.Errorf("tool_result_snip_ratio = %v, want %v", got.Agent.ToolResultSnipRatio, orig.Agent.ToolResultSnipRatio)
428 }
429 if got.Agent.CompactRatio != orig.Agent.CompactRatio {
430 t.Errorf("compact_ratio = %v, want %v", got.Agent.CompactRatio, orig.Agent.CompactRatio)
431 }
432 if got.Agent.CompactForceRatio != orig.Agent.CompactForceRatio {
433 t.Errorf("compact_force_ratio = %v, want %v", got.Agent.CompactForceRatio, orig.Agent.CompactForceRatio)
434 }
435 if strings.Join(got.Agent.Keep, ",") != strings.Join(orig.Agent.Keep, ",") {
436 t.Errorf("keep = %v, want %v", got.Agent.Keep, orig.Agent.Keep)
437 }
438 if got.Agent.RecentKeep != orig.Agent.RecentKeep {
439 t.Errorf("recent_keep = %d, want %d", got.Agent.RecentKeep, orig.Agent.RecentKeep)
440 }
441 if got.Agent.SystemPrompt != orig.Agent.SystemPrompt {
442 t.Errorf("system_prompt mismatch:\n got %q\nwant %q", got.Agent.SystemPrompt, orig.Agent.SystemPrompt)
443 }
444 if !got.LSP.Enabled {
445 t.Error("lsp.enabled = false, want true")
446 }
447 if got.Environment.Enabled == nil || *got.Environment.Enabled {
448 t.Errorf("environment.enabled = %+v, want false", got.Environment.Enabled)
449 }
450 if !reflect.DeepEqual(got.Environment.Tools, orig.Environment.Tools) {
451 t.Errorf("environment.tools = %v, want %v", got.Environment.Tools, orig.Environment.Tools)
452 }
453 lua := got.LSP.Servers["lua"]
454 if lua.Command != "lua-language-server" || lua.LanguageID != "lua" || lua.InstallHint != "install lua-language-server" {
455 t.Errorf("lsp.servers.lua scalar fields not preserved: %+v", lua)
456 }
457 if len(lua.Args) != 1 || lua.Args[0] != "--stdio" {
458 t.Errorf("lsp.servers.lua.args = %v, want [--stdio]", lua.Args)
459 }
460 if lua.Env["LUA_PATH"] != "./?.lua" {
461 t.Errorf("lsp.servers.lua.env = %v, want LUA_PATH", lua.Env)
462 }
463 if len(lua.Extensions) != 3 || lua.Extensions[2] != ".gui_script" {
464 t.Errorf("lsp.servers.lua.extensions = %v", lua.Extensions)
465 }
466 if got.Agent.SubagentModel != "mimo-pro" {
467 t.Errorf("subagent_model = %q, want mimo-pro", got.Agent.SubagentModel)
468 }
469 if got.Agent.SubagentModels["review"] != "deepseek-pro" {
470 t.Errorf("subagent_models.review = %q, want deepseek-pro", got.Agent.SubagentModels["review"])
471 }
472 if got.Agent.MaxSubagentDepth != 3 {
473 t.Errorf("max_subagent_depth = %d, want 3", got.Agent.MaxSubagentDepth)
474 }
475 if got.Tools.BashTimeoutSeconds == nil || *got.Tools.BashTimeoutSeconds != 900 {
476 t.Errorf("tools.bash_timeout_seconds = %v, want 900", got.Tools.BashTimeoutSeconds)
477 }
478 if got.Tools.BackgroundJobs.StalledWarningSeconds == nil || *got.Tools.BackgroundJobs.StalledWarningSeconds != 30 {
479 t.Errorf("tools.background_jobs.stalled_warning_seconds = %v, want 30", got.Tools.BackgroundJobs.StalledWarningSeconds)
480 }
481 if got.Tools.Shell.Prefer != "bash" {
482 t.Errorf("tools.shell.prefer = %q, want bash", got.Tools.Shell.Prefer)
483 }
484 if got.Tools.Shell.Path != "/usr/local/bin/bash" {
485 t.Errorf("tools.shell.path = %q, want /usr/local/bin/bash", got.Tools.Shell.Path)
486 }
487 if g, _ := got.Provider("mimo-pro"); g == nil || g.BaseURL != "http://localhost:8000/v1" || g.ChatURL != "http://localhost:8000/v1/chat/completions" || g.ModelsURL != "http://localhost:8000/v1/models" || g.ReasoningProtocol != "openai" {
488 t.Errorf("mimo-pro endpoint fields not preserved: %+v", g)
489 }
490 if g, _ := got.Provider("mimo-pro"); g == nil || g.PresetID != "mimo-api" || g.PresetVersion != ProviderPresetVersion {
491 t.Errorf("mimo-pro preset metadata not preserved: %+v", g)
492 }
493 if g, _ := got.Provider("deepseek-flash"); g == nil || g.Effort != "max" {
494 t.Errorf("deepseek-flash effort not preserved: %+v", g)
495 }
496 if len(got.Providers) != len(orig.Providers) {
497 t.Errorf("providers count = %d, want %d", len(got.Providers), len(orig.Providers))
498 }
499 if got.Permissions.Mode != "deny" {
500 t.Errorf("permissions.mode = %q, want deny", got.Permissions.Mode)
501 }
502 if len(got.Permissions.Deny) != 1 || got.Permissions.Deny[0] != "Bash(rm -rf*)" {
503 t.Errorf("permissions.deny = %v, want [Bash(rm -rf*)]", got.Permissions.Deny)
504 }
505 if len(got.Permissions.Allow) != 2 {
506 t.Errorf("permissions.allow = %v, want 2 entries", got.Permissions.Allow)
507 }
508 if got.Network.ProxyMode != "custom" || got.Network.Proxy.Type != "socks5" || got.Network.Proxy.Port != 7890 {
509 t.Errorf("network proxy not preserved: %+v", got.Network)
510 }
511 if len(got.Skills.Paths) != 2 || got.Skills.Paths[0] != "~/my-skills" {
512 t.Errorf("skills.paths = %v", got.Skills.Paths)
513 }
514 if len(got.Skills.ExcludedPaths) != 1 || got.Skills.ExcludedPaths[0] != "~/.agents/skills" {
515 t.Errorf("skills.excluded_paths = %v", got.Skills.ExcludedPaths)
516 }
517 if len(got.Skills.DisabledSkills) != 2 || got.Skills.DisabledSkills[0] != "review" || got.Skills.DisabledSkills[1] != "explore" {
518 t.Errorf("skills.disabled_skills = %v", got.Skills.DisabledSkills)
519 }
520 if got.SkillMaxDepth() != 2 {
521 t.Errorf("skills.max_depth = %d, want 2", got.SkillMaxDepth())
522 }
523 if len(got.Plugins) != 2 {
524 t.Fatalf("plugins count = %d, want 2", len(got.Plugins))
525 }
526 stripe := got.Plugins[1]
527 if stripe.Name != "stripe" || stripe.Type != "http" || stripe.URL != "https://mcp.stripe.com" {
528 t.Errorf("http plugin not preserved: %+v", stripe)
529 }
530 if stripe.Headers["Authorization"] != "Bearer x" {
531 t.Errorf("plugin headers not preserved: %v", stripe.Headers)
532 }
533 if strings.Contains(rendered, "trusted_read_only_tools") {
534 t.Errorf("removed plugin reader setting survived render: entry=%+v\n%s", stripe, rendered)
535 }
536 if stripe.AutoStart == nil || *stripe.AutoStart {
537 t.Errorf("auto_start should render and parse as false, got %+v", stripe.AutoStart)
538 }
539 if stripe.Tier != "" {
540 t.Errorf("plugin tier should be omitted from new config, got %q", stripe.Tier)
541 }
542 if strings.Contains(rendered, "\ntier") {
543 t.Errorf("rendered config should not contain MCP tier fields:\n%s", rendered)
544 }
545 }
546
547 func TestRenderTOMLDocumentsPlanModeReadOnlyCommands(t *testing.T) {
548 cfg := Default()
549 cfg.Agent.PlanModeReadOnlyCommands = []string{"gh issue view"}
550
551 rendered := RenderTOML(cfg)
552 var got Config
553 if _, err := toml.Decode(rendered, &got); err != nil {
554 t.Fatalf("rendered TOML does not parse: %v\n%s", err, rendered)
555 }
556 if !strings.Contains(rendered, `plan_mode_read_only_commands = ["gh issue view"]`) {
557 t.Fatalf("rendered config should preserve plan_mode_read_only_commands:\n%s", rendered)
558 }
559 if !strings.Contains(rendered, "legacy compatibility only") || !strings.Contains(rendered, "Plan bash uses Permissions") {
560 t.Fatalf("rendered config should document legacy plan_mode_read_only_commands semantics:\n%s", rendered)
561 }
562 if !reflect.DeepEqual(got.Agent.PlanModeReadOnlyCommands, cfg.Agent.PlanModeReadOnlyCommands) {
563 t.Fatalf("PlanModeReadOnlyCommands round trip = %v, want %v", got.Agent.PlanModeReadOnlyCommands, cfg.Agent.PlanModeReadOnlyCommands)
564 }
565 }
566
567 func TestRenderTOMLDropsRetiredMCPPolicyFields(t *testing.T) {
568 var cfg Config
569 if _, err := toml.Decode(`[[plugins]]
570 name = "github"
571 command = "github-mcp"
572 trusted_read_only_tools = ["issue_read", "pull_request_read"]
573 default_tools_approval_mode = "writes"
574 approvals_reviewer = "auto_review"
575
576 [plugins.tools.wipe]
577 approval_mode = "prompt"
578 `, &cfg); err != nil {
579 t.Fatalf("legacy config should still decode: %v", err)
580 }
581
582 rendered := RenderTOML(&cfg)
583 for _, retired := range []string{"trusted_read_only_tools", "default_tools_approval_mode", "approvals_reviewer", "\napproval_mode ="} {
584 if strings.Contains(rendered, retired) {
585 t.Fatalf("rendered config retained retired MCP field %q:\n%s", retired, rendered)
586 }
587 }
588
589 var got Config
590 if _, err := toml.Decode(rendered, &got); err != nil {
591 t.Fatalf("rendered TOML does not parse: %v\n%s", err, rendered)
592 }
593 }
594
595 func TestRenderTOMLPreservesMCPTimeouts(t *testing.T) {
596 cfg := Default()
597 cfg.Tools.MCPCallTimeoutSeconds = intPtr(450)
598 cfg.Tools.MCPStartupTimeoutSeconds = intPtr(45)
599 cfg.Plugins = []PluginEntry{{
600 Name: "maker",
601 Command: "maker-mcp",
602 StartupTimeoutSeconds: 60,
603 CallTimeoutSeconds: 600,
604 ToolTimeoutSeconds: map[string]int{
605 "generate/video": 1800,
606 "search": 120,
607 },
608 }}
609
610 rendered := RenderTOML(cfg)
611 for _, want := range []string{
612 "mcp_call_timeout_seconds = 450",
613 "mcp_startup_timeout_seconds = 45",
614 "startup_timeout_seconds = 60",
615 "call_timeout_seconds = 600",
616 `tool_timeout_seconds = { "generate/video" = 1800, "search" = 120 }`,
617 "Raw MCP tool names",
618 } {
619 if !strings.Contains(rendered, want) {
620 t.Fatalf("rendered config missing %q:\n%s", want, rendered)
621 }
622 }
623
624 var got Config
625 if _, err := toml.Decode(rendered, &got); err != nil {
626 t.Fatalf("rendered TOML does not parse: %v\n%s", err, rendered)
627 }
628 if got.Tools.MCPCallTimeoutSeconds == nil || *got.Tools.MCPCallTimeoutSeconds != 450 {
629 t.Fatalf("MCPCallTimeoutSeconds round trip = %v, want 450", got.Tools.MCPCallTimeoutSeconds)
630 }
631 if got.Tools.MCPStartupTimeoutSeconds == nil || *got.Tools.MCPStartupTimeoutSeconds != 45 {
632 t.Fatalf("MCPStartupTimeoutSeconds round trip = %v, want 45", got.Tools.MCPStartupTimeoutSeconds)
633 }
634 if got.Plugins[0].StartupTimeoutSeconds != 60 {
635 t.Fatalf("StartupTimeoutSeconds round trip = %d, want 60", got.Plugins[0].StartupTimeoutSeconds)
636 }
637 if got.Plugins[0].CallTimeoutSeconds != 600 {
638 t.Fatalf("CallTimeoutSeconds round trip = %d, want 600", got.Plugins[0].CallTimeoutSeconds)
639 }
640 if !reflect.DeepEqual(got.Plugins[0].ToolTimeoutSeconds, cfg.Plugins[0].ToolTimeoutSeconds) {
641 t.Fatalf("ToolTimeoutSeconds round trip = %v, want %v", got.Plugins[0].ToolTimeoutSeconds, cfg.Plugins[0].ToolTimeoutSeconds)
642 }
643 }
644
645 func TestRenderTOMLCreationLayoutStyle(t *testing.T) {
646 c := Default()
647 if err := c.SetDesktopLayoutStyle("creation"); err != nil {
648 t.Fatalf("SetDesktopLayoutStyle: %v", err)
649 }
650 rendered := RenderTOML(c)
651 var got Config
652 if _, err := toml.Decode(rendered, &got); err != nil {
653 t.Fatalf("rendered TOML does not parse: %v\n---\n%s", err, rendered)
654 }
655 if got.Desktop.LayoutStyle != "creation" {
656 t.Errorf("desktop.layout_style = %q, want creation", got.Desktop.LayoutStyle)
657 }
658 if got.DesktopLayoutStyle() != "creation" {
659 t.Errorf("DesktopLayoutStyle() = %q, want creation", got.DesktopLayoutStyle())
660 }
661 }
662
663 func TestScopedRenderPreservesLSPConfig(t *testing.T) {
664 const src = `
665 config_version = 4
666 default_model = "mimo"
667
668 [lsp]
669 enabled = true
670
671 [lsp.servers.lua]
672 command = "lua-language-server"
673 args = ["--stdio"]
674 env = { LUA_PATH = "./?.lua" }
675 language_id = "lua"
676 extensions = [".lua", ".script", ".gui_script"]
677 install_hint = "install lua-language-server"
678
679 [lsp.servers."c++"]
680 command = "clangd"
681 extensions = [".cc", ".cpp", ".hpp"]
682 `
683
684 var cfg Config
685 if _, err := toml.Decode(src, &cfg); err != nil {
686 t.Fatalf("decode source TOML: %v", err)
687 }
688
689 for _, scope := range []RenderScope{RenderScopeFull, RenderScopeUser, RenderScopeProject} {
690 t.Run(string(scope), func(t *testing.T) {
691 rendered := RenderTOMLForScope(&cfg, scope)
692 if !strings.Contains(rendered, "[lsp]") {
693 t.Fatalf("render missing [lsp]:\n%s", rendered)
694 }
695 if !strings.Contains(rendered, "[lsp.servers.lua]") {
696 t.Fatalf("render missing [lsp.servers.lua]:\n%s", rendered)
697 }
698 if !strings.Contains(rendered, `[lsp.servers."c++"]`) {
699 t.Fatalf("render missing quoted c++ server key:\n%s", rendered)
700 }
701
702 var got Config
703 if _, err := toml.Decode(rendered, &got); err != nil {
704 t.Fatalf("decode rendered TOML: %v\n---\n%s", err, rendered)
705 }
706 if !got.LSP.Enabled {
707 t.Fatalf("lsp.enabled = false, want true")
708 }
709 lua, ok := got.LSP.Servers["lua"]
710 if !ok {
711 t.Fatalf("lsp.servers.lua missing after round-trip: %+v", got.LSP.Servers)
712 }
713 if lua.Command != "lua-language-server" || lua.LanguageID != "lua" || lua.InstallHint != "install lua-language-server" {
714 t.Fatalf("lsp.servers.lua scalar fields not preserved: %+v", lua)
715 }
716 if len(lua.Args) != 1 || lua.Args[0] != "--stdio" {
717 t.Fatalf("lsp.servers.lua.args = %v, want [--stdio]", lua.Args)
718 }
719 if lua.Env["LUA_PATH"] != "./?.lua" {
720 t.Fatalf("lsp.servers.lua.env = %v, want LUA_PATH", lua.Env)
721 }
722 if len(lua.Extensions) != 3 || lua.Extensions[0] != ".lua" || lua.Extensions[2] != ".gui_script" {
723 t.Fatalf("lsp.servers.lua.extensions = %v", lua.Extensions)
724 }
725 cpp, ok := got.LSP.Servers["c++"]
726 if !ok {
727 t.Fatalf("lsp.servers.c++ missing after round-trip: %+v", got.LSP.Servers)
728 }
729 if cpp.Command != "clangd" || len(cpp.Extensions) != 3 || cpp.Extensions[1] != ".cpp" {
730 t.Fatalf("lsp.servers.c++ not preserved: %+v", cpp)
731 }
732 })
733 }
734 }
735
736 func BenchmarkRenderTOMLWithLSPServers(b *testing.B) {
737 cfg := Default()
738 cfg.LSP.Servers = make(map[string]LSPServer, 64)
739 for i := 0; i < 64; i++ {
740 lang := "lang" + strconv.Itoa(i)
741 cfg.LSP.Servers[lang] = LSPServer{
742 Command: "server-" + strconv.Itoa(i),
743 Args: []string{"--stdio", "--flag"},
744 Env: map[string]string{"SERVER_MODE": "stdio", "SERVER_ROOT": "."},
745 LanguageID: lang,
746 Extensions: []string{"." + lang, "." + lang + "x"},
747 InstallHint: "install server-" + strconv.Itoa(i),
748 }
749 }
750
751 b.ReportAllocs()
752 for i := 0; i < b.N; i++ {
753 rendered := RenderTOML(cfg)
754 if len(rendered) == 0 {
755 b.Fatal("empty render")
756 }
757 }
758 }
759
760 func TestNotificationsDefaultsKeepEventSwitchesEnabled(t *testing.T) {
761 cfg := Default()
762 if cfg.Notifications.Enabled {
763 t.Fatal("notifications.enabled default = true, want false")
764 }
765 if !cfg.Notifications.TurnDone || !cfg.Notifications.ApprovalRequest || !cfg.Notifications.AskRequest {
766 t.Fatalf("notification event switches default off: %+v", cfg.Notifications)
767 }
768
769 if _, err := toml.Decode("[notifications]\nenabled = true\n", cfg); err != nil {
770 t.Fatalf("decode notifications: %v", err)
771 }
772 if !cfg.Notifications.Enabled || !cfg.Notifications.TurnDone || !cfg.Notifications.ApprovalRequest || !cfg.Notifications.AskRequest {
773 t.Fatalf("enabled-only config should keep event switches on: %+v", cfg.Notifications)
774 }
775 }
776
777 func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) {
778 c := Default()
779 c.Language = "zh"
780 c.Desktop.Language = "zh"
781 c.Desktop.Currency = "CNY"
782 c.Desktop.Theme = "dark"
783 c.Desktop.ThemeStyle = "graphite"
784 c.Desktop.CloseBehavior = "background"
785 c.Desktop.StatusBarStyle = "text"
786 c.Desktop.DefaultToolApprovalMode = "auto"
787 c.Desktop.CheckUpdates = boolPtr(false)
788 c.Desktop.UpdateChannel = "preview"
789 c.Agent.RecoveryModel = "deepseek-pro"
790 c.Agent.RecoveryTemperature = 0.2
791
792 user := RenderTOMLForScope(c, RenderScopeUser)
793 for _, want := range []string{"config_version = 5", "[desktop]", `currency = "CNY"`, `theme = "dark"`, `terminal_theme = "auto"`, `close_behavior = "background"`, `status_bar_style = "text"`, `default_tool_approval_mode = "auto"`, `check_updates = false`, `recovery_model = "deepseek-pro"`, "[notifications]", "[tools.shell]"} {
794 if !strings.Contains(user, want) {
795 t.Fatalf("user render missing %q:\n%s", want, user)
796 }
797 }
798 if strings.Contains(user, "update_channel") || strings.Contains(user, "[cli]") {
799 t.Fatalf("user render retained retired update channel:\n%s", user)
800 }
801
802 project := RenderTOMLForScope(c, RenderScopeProject)
803 for _, forbidden := range []string{"[desktop]", "[notifications]", "close_behavior =", "default_tool_approval_mode =", "default_auto_recovery_checkpoint =", "check_updates =", "update_channel =", "max_steps", "planner_max_steps"} {
804 if strings.Contains(project, forbidden) {
805 t.Fatalf("project render should not contain %q:\n%s", forbidden, project)
806 }
807 }
808 for _, retired := range []string{"default_auto_recovery_checkpoint", "auto_recovery_checkpoint"} {
809 if strings.Contains(user, retired) || strings.Contains(project, retired) {
810 t.Fatalf("retired Auto Guard key %q must not be rendered:\nuser:\n%s\nproject:\n%s", retired, user, project)
811 }
812 }
813 if strings.Contains(project, "\nsystem_prompt = \"\"\"") {
814 t.Fatalf("project render should not pin the built-in system prompt:\n%s", project)
815 }
816 if !strings.Contains(project, "# system_prompt =") {
817 t.Fatalf("project render should leave a system prompt hint:\n%s", project)
818 }
819 for _, want := range []string{`recovery_model = "deepseek-pro"`} {
820 if !strings.Contains(project, want) {
821 t.Fatalf("project render missing %q:\n%s", want, project)
822 }
823 }
824 if strings.Contains(user, "auto_plan") || strings.Contains(project, "auto_plan") {
825 t.Fatalf("retired auto-plan keys must not be rendered:\nuser:\n%s\nproject:\n%s", user, project)
826 }
827 if strings.Contains(user, "recovery_temperature") || strings.Contains(project, "recovery_temperature") {
828 t.Fatalf("deprecated recovery_temperature must not be rendered:\nuser:\n%s\nproject:\n%s", user, project)
829 }
830 }
831
832 func TestScopedRenderKeepsPluginsInTheirOwningConfig(t *testing.T) {
833 cfg := Default()
834 cfg.Plugins = []PluginEntry{
835 {Name: "unknown", Command: "unknown-mcp"},
836 {Name: "user", Command: "user-mcp", Source: MCPSourceUserConfig},
837 {Name: "project", Command: "project-mcp", Source: MCPSourceProjectConfig},
838 {Name: "mcp-json", Command: "json-mcp", Source: MCPSourceProjectMCPJSON},
839 {Name: "legacy", Command: "legacy-mcp", Source: MCPSourceLegacyUser},
840 {Name: "package", Command: "package-mcp", Source: MCPSourcePluginPackage},
841 }
842
843 tests := []struct {
844 name string
845 body string
846 want []string
847 avoid []string
848 }{
849 {name: "full", body: RenderTOMLForScope(cfg, RenderScopeFull), want: []string{"unknown", "user", "project", "mcp-json", "legacy", "package"}},
850 {name: "user", body: RenderTOMLForScope(cfg, RenderScopeUser), want: []string{"unknown", "user"}, avoid: []string{"project", "mcp-json", "legacy", "package"}},
851 {name: "project", body: RenderTOMLForScope(cfg, RenderScopeProject), want: []string{"unknown", "project"}, avoid: []string{"user", "mcp-json", "legacy", "package"}},
852 {name: "project delta", body: RenderTOMLProjectDelta(cfg), want: []string{"unknown", "project"}, avoid: []string{"user", "mcp-json", "legacy", "package"}},
853 }
854 for _, tt := range tests {
855 t.Run(tt.name, func(t *testing.T) {
856 for _, name := range tt.want {
857 if !strings.Contains(tt.body, `name = "`+name+`"`) {
858 t.Fatalf("render missing plugin %q:\n%s", name, tt.body)
859 }
860 }
861 for _, name := range tt.avoid {
862 if strings.Contains(tt.body, `name = "`+name+`"`) {
863 t.Fatalf("render leaked plugin %q:\n%s", name, tt.body)
864 }
865 }
866 })
867 }
868 }
869
870 func TestProjectDeltaRendersRecoveryReviewerOverride(t *testing.T) {
871 c := Default()
872 c.Agent.RecoveryModel = "deepseek-pro"
873 c.Agent.RecoveryTemperature = 0.2
874
875 delta := RenderTOMLProjectDelta(c)
876 for _, want := range []string{"[agent]", `recovery_model = "deepseek-pro"`} {
877 if !strings.Contains(delta, want) {
878 t.Fatalf("project delta missing %q:\n%s", want, delta)
879 }
880 }
881 if strings.Contains(delta, "recovery_temperature") {
882 t.Fatalf("deprecated recovery_temperature rendered:\n%s", delta)
883 }
884 }
885
886 func TestProjectDeltaRendersToolsShellOverrides(t *testing.T) {
887 c := Default()
888 c.Tools.Shell.Prefer = "bash"
889 c.Tools.Shell.Path = "/usr/local/bin/bash"
890
891 delta := RenderTOMLProjectDelta(c)
892 for _, want := range []string{"[tools.shell]", `prefer = "bash"`, `path = "/usr/local/bin/bash"`} {
893 if !strings.Contains(delta, want) {
894 t.Fatalf("project delta missing %q:\n%s", want, delta)
895 }
896 }
897 if strings.Contains(delta, "[tools]\n\n") {
898 t.Fatalf("project delta should not emit an empty [tools] block:\n%s", delta)
899 }
900
901 got := Default()
902 if _, err := toml.Decode(delta, got); err != nil {
903 t.Fatalf("decode project delta: %v\n%s", err, delta)
904 }
905 if got.Tools.Shell.Prefer != "bash" || got.Tools.Shell.Path != "/usr/local/bin/bash" {
906 t.Fatalf("tools.shell = %+v, want bash with path", got.Tools.Shell)
907 }
908 }
909
910 func TestResponsesProviderModeRoundTripsInUserAndProjectRender(t *testing.T) {
911 legacyFalse := false
912 cfg := Default()
913 cfg.Providers = append(cfg.Providers, ProviderEntry{
914 Name: "responses-test", Kind: "responses", BaseURL: "https://example.com/v1",
915 Model: "model", APIKeyEnv: "RESPONSES_API_KEY",
916 ResponsesMode: "stateful", ResponsesStateful: &legacyFalse,
917 })
918
919 for _, rendered := range []string{RenderTOMLForScope(cfg, RenderScopeUser), RenderTOMLProjectDelta(cfg)} {
920 if !strings.Contains(rendered, `responses_mode = "stateful"`) || !strings.Contains(rendered, "responses_stateful = false") {
921 t.Fatalf("responses settings missing from render:\n%s", rendered)
922 }
923 var decoded Config
924 if _, err := toml.Decode(rendered, &decoded); err != nil {
925 t.Fatalf("decode responses config: %v\n%s", err, rendered)
926 }
927 entry, ok := decoded.Provider("responses-test")
928 if !ok || entry.ResponsesMode != "stateful" || entry.ResponsesStateful == nil || *entry.ResponsesStateful {
929 t.Fatalf("responses settings did not round-trip: %+v, found=%v", entry, ok)
930 }
931 }
932 }
933
934 func TestProjectDeltaRendersUICursorShape(t *testing.T) {
935 c := Default()
936 c.UI.CursorShape = "block"
937
938 delta := RenderTOMLProjectDelta(c)
939 for _, want := range []string{"[ui]", `cursor_shape = "block"`} {
940 if !strings.Contains(delta, want) {
941 t.Fatalf("project delta missing %q:\n%s", want, delta)
942 }
943 }
944
945 got := Default()
946 if _, err := toml.Decode(delta, got); err != nil {
947 t.Fatalf("decode project delta: %v\n%s", err, delta)
948 }
949 if got.UICursorShape() != "block" {
950 t.Fatalf("ui.cursor_shape = %q, want block", got.UICursorShape())
951 }
952 }
953
954 func TestShowTurnUsageDefaultsOnAndRendersFalseOverride(t *testing.T) {
955 c := Default()
956 if !c.UI.ShowTurnUsage {
957 t.Fatal("ui.show_turn_usage should default to true")
958 }
959
960 c.UI.ShowTurnUsage = false
961 delta := RenderTOMLProjectDelta(c)
962 for _, want := range []string{"[ui]", "show_turn_usage = false"} {
963 if !strings.Contains(delta, want) {
964 t.Fatalf("project delta missing %q:\n%s", want, delta)
965 }
966 }
967
968 got := Default()
969 if _, err := toml.Decode(delta, got); err != nil {
970 t.Fatalf("decode project delta: %v\n%s", err, delta)
971 }
972 if got.UI.ShowTurnUsage {
973 t.Fatal("ui.show_turn_usage false override did not round-trip")
974 }
975 }
976
977 func TestProjectRenderPreservesNonDefaultLegacySections(t *testing.T) {
978 c := Default()
979 c.UI.Theme = "light"
980 c.UI.CloseBehavior = "quit"
981 c.Network.ProxyMode = "custom"
982 c.Network.Proxy.Server = "127.0.0.1"
983 c.Network.Proxy.Port = 7890
984
985 project := RenderTOMLForScope(c, RenderScopeProject)
986 for _, want := range []string{"[ui]", `theme = "light"`, `close_behavior = "quit"`, "[network]", `proxy_mode = "custom"`, `server = "127.0.0.1"`} {
987 if !strings.Contains(project, want) {
988 t.Fatalf("project render missing legacy/non-default %q:\n%s", want, project)
989 }
990 }
991 }
992
993 func TestRenderTOMLRoundTripsPerModelPrices(t *testing.T) {
994 orig := Default()
995 orig.Providers = []ProviderEntry{{
996 Name: "deepseek",
997 Kind: "openai",
998 BaseURL: "https://api.deepseek.com",
999 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
1000 Default: "deepseek-v4-flash",
1001 APIKeyEnv: "DEEPSEEK_API_KEY",
1002 Prices: DeepSeekV4PricesForCurrency("CNY"),
1003 }}
1004
1005 var got Config
1006 if _, err := toml.Decode(RenderTOML(orig), &got); err != nil {
1007 t.Fatalf("rendered TOML does not parse: %v", err)
1008 }
1009 p, ok := got.Provider("deepseek")
1010 if !ok {
1011 t.Fatal("deepseek provider missing after round trip")
1012 }
1013 if p.Prices["deepseek-v4-flash"].Input != 1 || p.Prices["deepseek-v4-pro"].Output != 6 {
1014 t.Fatalf("prices after round trip = %+v", p.Prices)
1015 }
1016 }
1017
1018 func TestRenderTOMLRoundTripsVisionModels(t *testing.T) {
1019 orig := Default()
1020 orig.Providers = []ProviderEntry{
1021 {
1022 Name: "custom",
1023 Kind: "openai",
1024 BaseURL: "https://proxy.example.com/v1",
1025 Models: []string{"text-only", "qwen-vl-plus"},
1026 Default: "text-only",
1027 APIKeyEnv: "CUSTOM_API_KEY",
1028 VisionModels: []string{"qwen-vl-plus"},
1029 VisionDetail: "low",
1030 },
1031 {
1032 Name: "disabled-vision",
1033 Kind: "openai",
1034 BaseURL: "https://proxy.example.com/v1",
1035 Models: []string{"qwen-vl-plus"},
1036 Default: "qwen-vl-plus",
1037 APIKeyEnv: "CUSTOM_API_KEY",
1038 VisionModels: []string{},
1039 },
1040 }
1041
1042 rendered := RenderTOML(orig)
1043 if !strings.Contains(rendered, `vision_models = ["qwen-vl-plus"]`) {
1044 t.Fatalf("rendered TOML missing vision_models:\n%s", rendered)
1045 }
1046 if !strings.Contains(rendered, `vision_models = []`) {
1047 t.Fatalf("rendered TOML missing explicit empty vision_models:\n%s", rendered)
1048 }
1049 if !strings.Contains(rendered, `vision_detail = "low"`) {
1050 t.Fatalf("rendered TOML missing vision_detail:\n%s", rendered)
1051 }
1052
1053 var got Config
1054 if _, err := toml.Decode(rendered, &got); err != nil {
1055 t.Fatalf("rendered TOML does not parse: %v", err)
1056 }
1057 p, ok := got.Provider("custom")
1058 if !ok {
1059 t.Fatal("custom provider missing after round trip")
1060 }
1061 if !reflect.DeepEqual(p.VisionModels, []string{"qwen-vl-plus"}) {
1062 t.Fatalf("vision_models after round trip = %v, want [qwen-vl-plus]", p.VisionModels)
1063 }
1064 if p.VisionDetail != "low" {
1065 t.Fatalf("vision_detail after round trip = %q, want low", p.VisionDetail)
1066 }
1067 disabled, ok := got.Provider("disabled-vision")
1068 if !ok {
1069 t.Fatal("disabled-vision provider missing after round trip")
1070 }
1071 if disabled.VisionModels == nil || len(disabled.VisionModels) != 0 {
1072 t.Fatalf("disabled-vision vision_models after round trip = %#v, want explicit empty list", disabled.VisionModels)
1073 }
1074 }
1075
1076 func TestRenderTOMLRoundTripsProviderHeadersAndModelOverrides(t *testing.T) {
1077 orig := Default()
1078 orig.Providers = []ProviderEntry{{
1079 Name: "gateway",
1080 Kind: "openai",
1081 BaseURL: "https://gateway.example/v1",
1082 Models: []string{"deepseek-v4-flash", "plain-chat"},
1083 Default: "plain-chat",
1084 APIKeyEnv: "GATEWAY_API_KEY",
1085 Headers: map[string]string{
1086 "HTTP-Referer": "https://app.example",
1087 "X-Title": "Reasonix",
1088 },
1089 ExtraBody: map[string]any{
1090 "enable_thinking": true,
1091 "top_p": 0.8,
1092 "metadata": map[string]any{
1093 "mode": "fast",
1094 },
1095 },
1096 AuthHeader: true,
1097 MaxOutputTokens: 16_384,
1098 ModelOverrides: map[string]ProviderModelOverride{
1099 "deepseek-v4-flash": {
1100 ReasoningProtocol: ReasoningProtocolDeepSeek,
1101 SupportedEfforts: []string{"high", "max"},
1102 DefaultEffort: "high",
1103 Vision: boolPtr(false),
1104 ContextWindow: 262_144,
1105 MaxOutputTokens: 32_768,
1106 },
1107 },
1108 }}
1109
1110 rendered := RenderTOML(orig)
1111 if !strings.Contains(rendered, `headers = { HTTP-Referer = "https://app.example", X-Title = "Reasonix" }`) {
1112 t.Fatalf("rendered TOML missing headers:\n%s", rendered)
1113 }
1114 if !strings.Contains(rendered, `extra_body`) || !strings.Contains(rendered, `"enable_thinking" = true`) {
1115 t.Fatalf("rendered TOML missing extra_body:\n%s", rendered)
1116 }
1117 if !strings.Contains(rendered, `auth_header = true`) {
1118 t.Fatalf("rendered TOML missing auth_header:\n%s", rendered)
1119 }
1120 if !strings.Contains(rendered, `max_output_tokens = 16384`) || !strings.Contains(rendered, `model_overrides`) || !strings.Contains(rendered, `reasoning_protocol = "deepseek"`) || !strings.Contains(rendered, `context_window = 262144`) || !strings.Contains(rendered, `max_output_tokens = 32768`) {
1121 t.Fatalf("rendered TOML missing model overrides:\n%s", rendered)
1122 }
1123
1124 var got Config
1125 if _, err := toml.Decode(rendered, &got); err != nil {
1126 t.Fatalf("rendered TOML does not parse: %v\n%s", err, rendered)
1127 }
1128 p, ok := got.Provider("gateway")
1129 if !ok {
1130 t.Fatal("gateway provider missing after round trip")
1131 }
1132 if p.Headers["HTTP-Referer"] != "https://app.example" || p.Headers["X-Title"] != "Reasonix" {
1133 t.Fatalf("headers after round trip = %+v", p.Headers)
1134 }
1135 if p.ExtraBody["enable_thinking"] != true || p.ExtraBody["top_p"] != 0.8 {
1136 t.Fatalf("extra_body after round trip = %+v", p.ExtraBody)
1137 }
1138 if !p.AuthHeader {
1139 t.Fatal("auth_header after round trip = false, want true")
1140 }
1141 if p.MaxOutputTokens != 16_384 {
1142 t.Fatalf("provider max_output_tokens after round trip = %d, want 16384", p.MaxOutputTokens)
1143 }
1144 metadata, ok := p.ExtraBody["metadata"].(map[string]any)
1145 if !ok || metadata["mode"] != "fast" {
1146 t.Fatalf("extra_body metadata after round trip = %+v", p.ExtraBody["metadata"])
1147 }
1148 ov := p.ModelOverrides["deepseek-v4-flash"]
1149 if ov.ReasoningProtocol != ReasoningProtocolDeepSeek || !reflect.DeepEqual(ov.SupportedEfforts, []string{"high", "max"}) || ov.DefaultEffort != "high" || ov.Vision == nil || *ov.Vision || ov.ContextWindow != 262_144 || ov.MaxOutputTokens != 32_768 {
1150 t.Fatalf("model override after round trip = %+v", ov)
1151 }
1152
1153 // Older releases do not know context_window/max_output_tokens inside model
1154 // overrides, but their TOML decoder must still accept this release's config.
1155 type legacyModelOverride struct {
1156 ReasoningProtocol string `toml:"reasoning_protocol"`
1157 SupportedEfforts []string `toml:"supported_efforts"`
1158 DefaultEffort string `toml:"default_effort"`
1159 Vision *bool `toml:"vision"`
1160 }
1161 type legacyProvider struct {
1162 ModelOverrides map[string]legacyModelOverride `toml:"model_overrides"`
1163 }
1164 var legacy struct {
1165 Providers []legacyProvider `toml:"providers"`
1166 }
1167 if _, err := toml.Decode(rendered, &legacy); err != nil {
1168 t.Fatalf("legacy config shape cannot read per-model context window: %v", err)
1169 }
1170 }
1171
1172 func TestRenderStringMapQuotesNonBareTOMLKeys(t *testing.T) {
1173 rendered := renderStringMap(map[string]string{
1174 "github:gh-fix-ci": "deepseek-pro",
1175 "review": "deepseek-flash",
1176 })
1177 if !strings.Contains(rendered, `"github:gh-fix-ci" = "deepseek-pro"`) {
1178 t.Fatalf("non-bare key was not quoted: %s", rendered)
1179 }
1180 var got struct {
1181 M map[string]string `toml:"m"`
1182 }
1183 if _, err := toml.Decode("m = "+rendered, &got); err != nil {
1184 t.Fatalf("rendered inline map does not parse: %v (%s)", err, rendered)
1185 }
1186 if got.M["github:gh-fix-ci"] != "deepseek-pro" || got.M["review"] != "deepseek-flash" {
1187 t.Fatalf("decoded map = %+v", got.M)
1188 }
1189 }
1190
1191 func TestDesktopExternalOpenerUserScopeRoundTrip(t *testing.T) {
1192 cfg := Default()
1193 if err := cfg.SetDesktopExternalOpener("ghostty"); err != nil {
1194 t.Fatal(err)
1195 }
1196 rendered := RenderTOMLForScope(cfg, RenderScopeUser)
1197 if !strings.Contains(rendered, `external_opener = "ghostty"`) {
1198 t.Fatalf("user config omitted desktop external opener:\n%s", rendered)
1199 }
1200 if project := RenderTOMLForScope(cfg, RenderScopeProject); strings.Contains(project, "external_opener") {
1201 t.Fatalf("project config leaked user-only external opener:\n%s", project)
1202 }
1203 var decoded Config
1204 if _, err := toml.Decode(rendered, &decoded); err != nil {
1205 t.Fatalf("decode rendered user config: %v", err)
1206 }
1207 if got := decoded.DesktopExternalOpener(); got != "ghostty" {
1208 t.Fatalf("round-trip external opener = %q, want ghostty", got)
1209 }
1210 }
1211
1212 func TestRenderTOMLTablePathQuotesEachSegment(t *testing.T) {
1213 got := renderTOMLTablePath("lsp", "servers", "c++", "github:gh-fix-ci")
1214 want := `lsp.servers."c++"."github:gh-fix-ci"`
1215 if got != want {
1216 t.Fatalf("renderTOMLTablePath = %q, want %q", got, want)
1217 }
1218 }
1219
1220 func boolPtr(v bool) *bool { return &v }
1221
1222 func intPtr(v int) *int { return &v }
1223
1224 func TestRenderTOMLPreservesDesktopDisplayMode(t *testing.T) {
1225 c := Default()
1226 if err := c.SetDesktopDisplayMode("compact"); err != nil {
1227 t.Fatalf("SetDesktopDisplayMode: %v", err)
1228 }
1229 rendered := RenderTOMLForScope(c, RenderScopeUser)
1230 if !strings.Contains(rendered, `display_mode = "compact"`) {
1231 t.Fatalf("rendered user config missing display_mode:\n%s", rendered)
1232 }
1233 var got Config
1234 if _, err := toml.Decode(rendered, &got); err != nil {
1235 t.Fatalf("rendered TOML does not parse: %v\n---\n%s", err, rendered)
1236 }
1237 if got.DesktopDisplayMode() != "compact" {
1238 t.Fatalf("display_mode after round trip = %q, want compact", got.DesktopDisplayMode())
1239 }
1240 }
1241
1242 func TestRenderTOMLConversationWidthRoundTrip(t *testing.T) {
1243 c := Default()
1244 if err := c.SetDesktopConversationWidth("full"); err != nil {
1245 t.Fatalf("SetDesktopConversationWidth: %v", err)
1246 }
1247 rendered := RenderTOMLForScope(c, RenderScopeUser)
1248 if !strings.Contains(rendered, `conversation_width = "full"`) {
1249 t.Fatalf("rendered user config missing conversation_width:\n%s", rendered)
1250 }
1251 if project := RenderTOMLForScope(c, RenderScopeProject); strings.Contains(project, "conversation_width") {
1252 t.Fatalf("project config leaked user-only conversation_width:\n%s", project)
1253 }
1254
1255 var got Config
1256 if _, err := toml.Decode(rendered, &got); err != nil {
1257 t.Fatalf("rendered TOML does not parse: %v\n---\n%s", err, rendered)
1258 }
1259 if got.DesktopConversationWidth() != "full" {
1260 t.Fatalf("conversation_width after round trip = %q, want full", got.DesktopConversationWidth())
1261 }
1262
1263 if err := c.SetDesktopConversationWidth("standard"); err != nil {
1264 t.Fatalf("reset conversation width: %v", err)
1265 }
1266 if rendered := RenderTOMLForScope(c, RenderScopeUser); strings.Contains(rendered, "conversation_width") {
1267 t.Fatalf("default conversation_width should be omitted:\n%s", rendered)
1268 }
1269
1270 c.Desktop.ConversationWidth = " FULL "
1271 if rendered := RenderTOMLForScope(c, RenderScopeUser); !strings.Contains(rendered, `conversation_width = "full"`) {
1272 t.Fatalf("manually edited full width was not normalized:\n%s", rendered)
1273 }
1274 }
1275
1276 func TestRenderTOMLDefaultStepsOmitted(t *testing.T) {
1277 isolateUserConfigHome(t)
1278 out := RenderTOML(Default())
1279 agentLines := extractSectionLines(out, "[agent]")
1280 for _, line := range agentLines {
1281 if strings.Contains(line, "max_steps") || strings.Contains(line, "planner_max_steps") {
1282 t.Errorf("default step limits should be hidden from generated config, got: %s", line)
1283 }
1284 }
1285 }
1286
1287 func TestRenderTOMLWindowsSandboxDefaultAndExplicitEnforceDisabled(t *testing.T) {
1288 isolateUserConfigHome(t)
1289 setRuntimeGOOS(t, "windows")
1290
1291 defaultRendered := RenderTOMLForScope(Default(), RenderScopeUser)
1292 if !strings.Contains(defaultRendered, `bash = "off"`) {
1293 t.Fatalf("Windows default user config should render bash off:\n%s", defaultRendered)
1294 }
1295
1296 cfg := Default()
1297 cfg.Sandbox.Bash = "enforce"
1298 delta := RenderTOMLProjectDelta(cfg)
1299 if strings.Contains(delta, `[sandbox]`) || strings.Contains(delta, `bash = `) {
1300 t.Fatalf("Windows explicit enforce should not render as an effective project delta:\n%s", delta)
1301 }
1302 }
1303
1304 func extractSectionLines(toml, section string) []string {
1305 var lines []string
1306 inSection := false
1307 for _, line := range strings.Split(toml, "\n") {
1308 trimmed := strings.TrimSpace(line)
1309 if strings.HasPrefix(trimmed, section) {
1310 inSection = true
1311 continue
1312 }
1313 if inSection && strings.HasPrefix(trimmed, "[") && !strings.HasPrefix(trimmed, "[[") {
1314 break
1315 }
1316 if inSection {
1317 lines = append(lines, trimmed)
1318 }
1319 }
1320 return lines
1321 }
1322
1323 func TestRenderTOMLOmitsDeprecatedAgentStepLimits(t *testing.T) {
1324 isolateUserConfigHome(t)
1325 c := Default()
1326 c.Agent.MaxSteps = 5
1327 c.Agent.PlannerMaxSteps = 7
1328 out := RenderTOML(c)
1329 for _, line := range extractSectionLines(out, "[agent]") {
1330 if strings.Contains(line, "max_steps") || strings.Contains(line, "planner_max_steps") {
1331 t.Fatalf("deprecated step limit should never be rendered, got: %s", line)
1332 }
1333 }
1334 }
1335
1336 func TestLoadForEditIgnoresAndDropsDeprecatedAgentStepLimitsOnSave(t *testing.T) {
1337 isolateUserConfigHome(t)
1338 path := UserConfigPath()
1339 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
1340 t.Fatal(err)
1341 }
1342 if err := os.WriteFile(path, []byte("[agent]\nplanner_max_steps = 9\nmax_steps = 100\ntemperature = 0.4\n"), 0o644); err != nil {
1343 t.Fatal(err)
1344 }
1345
1346 cfg := LoadForEdit(path)
1347 if cfg.Agent.MaxSteps != 0 || cfg.Agent.PlannerMaxSteps != 0 {
1348 t.Fatalf("deprecated limits should normalize to zero, got max=%d planner=%d", cfg.Agent.MaxSteps, cfg.Agent.PlannerMaxSteps)
1349 }
1350 if cfg.Agent.Temperature != 0.4 {
1351 t.Fatalf("unrelated agent setting changed: temperature=%v", cfg.Agent.Temperature)
1352 }
1353 if err := cfg.SaveTo(path); err != nil {
1354 t.Fatal(err)
1355 }
1356 raw, err := os.ReadFile(path)
1357 if err != nil {
1358 t.Fatal(err)
1359 }
1360 if _, changed := stripLegacyAgentStepLimitLines(string(raw)); changed {
1361 t.Fatalf("saved config retained deprecated step limits:\n%s", raw)
1362 }
1363 }
1364
1365 func TestIsolatedHomeDirEmptyByDefault(t *testing.T) {
1366 t.Setenv("REASONIX_HOME", "")
1367 if got := IsolatedHomeDir(); got != "" {
1368 t.Fatalf("IsolatedHomeDir() = %q, want empty", got)
1369 }
1370 }
1371
1372 func TestIsolatedHomeDirReturnsCleanPath(t *testing.T) {
1373 raw := filepath.Join(t.TempDir(), "isolated-reasonix")
1374 t.Setenv("REASONIX_HOME", raw)
1375 got := IsolatedHomeDir()
1376 if filepath.Clean(got) != filepath.Clean(raw) {
1377 t.Fatalf("IsolatedHomeDir() = %q, want %q", got, raw)
1378 }
1379 }
1380
1381 func TestLegacyOSSupportDirEmptyWhenIsolated(t *testing.T) {
1382 isolateUserConfigHome(t)
1383 t.Setenv("REASONIX_HOME", filepath.Join(t.TempDir(), "isolated-home"))
1384 if got := legacyOSSupportDir(); got != "" {
1385 t.Fatalf("legacyOSSupportDir() = %q, want empty when isolated", got)
1386 }
1387 }
1388
1389 func TestLegacyXDGConfigPathsEmptyWhenIsolated(t *testing.T) {
1390 isolateUserConfigHome(t)
1391 t.Setenv("REASONIX_HOME", filepath.Join(t.TempDir(), "isolated-home"))
1392 if got := legacyXDGConfigPaths(); got != nil {
1393 t.Fatalf("legacyXDGConfigPaths() = %v, want nil when isolated", got)
1394 }
1395 }
1396
1397 func TestCacheDirHonorsReasonixHome(t *testing.T) {
1398 home := t.TempDir()
1399 isolated := filepath.Join(home, "isolated-home")
1400 t.Setenv("HOME", home)
1401 t.Setenv("USERPROFILE", home)
1402 t.Setenv("REASONIX_HOME", isolated)
1403
1404 got := CacheDir()
1405 want := filepath.Join(isolated, "cache")
1406 if filepath.Clean(got) != filepath.Clean(want) {
1407 t.Fatalf("CacheDir() = %q, want %q", got, want)
1408 }
1409 }
1410
1411 func TestCacheDirHonorsReasonixCacheHomeOverReasonixHome(t *testing.T) {
1412 home := t.TempDir()
1413 cacheHome := filepath.Join(home, "custom-cache")
1414 t.Setenv("HOME", home)
1415 t.Setenv("USERPROFILE", home)
1416 t.Setenv("REASONIX_HOME", filepath.Join(home, "isolated-home"))
1417 t.Setenv("REASONIX_CACHE_HOME", cacheHome)
1418
1419 got := CacheDir()
1420 want := cacheHome
1421 if filepath.Clean(got) != filepath.Clean(want) {
1422 t.Fatalf("CacheDir() = %q, want %q (REASONIX_CACHE_HOME must win)", got, want)
1423 }
1424 }
1425
1426 func TestUserConfigLoadPathNoLegacyFallbackWhenIsolated(t *testing.T) {
1427 home := isolateUserConfigHome(t)
1428 isolated := filepath.Join(home, "isolated-home")
1429 t.Setenv("REASONIX_HOME", isolated)
1430
1431 // Create a legacy config at the OS production path — it must not be loaded.
1432 productionHome := expectedDefaultReasonixHome(home)
1433 if err := os.MkdirAll(productionHome, 0o755); err != nil {
1434 t.Fatal(err)
1435 }
1436 if err := os.WriteFile(filepath.Join(productionHome, "config.toml"), []byte("default_model = \"production/model\"\n"), 0o600); err != nil {
1437 t.Fatal(err)
1438 }
1439
1440 // The primary config under isolated home does not exist yet.
1441 got := userConfigLoadPath()
1442 want := filepath.Join(isolated, "config.toml")
1443 if filepath.Clean(got) != filepath.Clean(want) {
1444 t.Fatalf("userConfigLoadPath() = %q, want %q (must not fall back to production legacy config)", got, want)
1445 }
1446 }
1447
1448 func TestCredentialSourceCandidatesSkipHomeEnvWhenIsolated(t *testing.T) {
1449 isolateUserConfigHome(t)
1450 t.Setenv("REASONIX_HOME", filepath.Join(t.TempDir(), "isolated-home"))
1451
1452 // Write a key into the production home .env — it must not appear as a source.
1453 if home, err := os.UserHomeDir(); err == nil {
1454 if err := os.WriteFile(filepath.Join(home, ".env"), []byte("LEAKED_KEY=leaked-value\n"), 0o600); err != nil {
1455 t.Fatal(err)
1456 }
1457 }
1458
1459 candidates := credentialSourceCandidates(".")
1460 for _, c := range candidates {
1461 if c.Kind == CredentialSourceHomeEnv {
1462 t.Fatalf("credentialSourceCandidates includes CredentialSourceHomeEnv when isolated: %v", c)
1463 }
1464 }
1465 }
1466
1467 func TestMigrateLegacyIfNeededSkipsWhenIsolated(t *testing.T) {
1468 home := isolateUserConfigHome(t)
1469 isolated := filepath.Join(home, "isolated-home")
1470 t.Setenv("REASONIX_HOME", isolated)
1471
1472 // Create a legacy config.json in production home — migration must skip it.
1473 legacyDir := filepath.Join(home, ".reasonix")
1474 if err := os.MkdirAll(legacyDir, 0o755); err != nil {
1475 t.Fatal(err)
1476 }
1477 if err := os.WriteFile(filepath.Join(legacyDir, "config.json"), []byte(`{"model":"production-model","apiKey":"sk-legacy"}`), 0o600); err != nil {
1478 t.Fatal(err)
1479 }
1480
1481 res, err := MigrateLegacyIfNeeded()
1482 if err != nil {
1483 t.Fatalf("MigrateLegacyIfNeeded() error = %v", err)
1484 }
1485 if res != nil {
1486 t.Fatalf("MigrateLegacyIfNeeded() = %+v, want nil when isolated", res)
1487 }
1488 }
1489
1490 // TestProjectConfigCannotOverrideSecrets pins [secrets] as a user-global
1491 // security control: a cloned repository's reasonix.toml must not be able to
1492 // opt the user into subprocess env stripping or sensitive-path hiding.
1493 func TestProjectConfigCannotOverrideSecrets(t *testing.T) {
1494 isolateUserConfigHome(t)
1495 t.Setenv("REASONIX_HOME", "")
1496 globalDir := filepath.Dir(UserConfigPath())
1497 if err := os.MkdirAll(globalDir, 0o755); err != nil {
1498 t.Fatal(err)
1499 }
1500 globalTOML := "[secrets]\nfilter_subprocess_env = false\nprotect_sensitive_files = false\n"
1501 if err := os.WriteFile(filepath.Join(globalDir, "config.toml"), []byte(globalTOML), 0o644); err != nil {
1502 t.Fatal(err)
1503 }
1504
1505 project := t.TempDir()
1506 projectTOML := "[secrets]\nfilter_subprocess_env = true\nprotect_sensitive_files = true\n"
1507 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(projectTOML), 0o644); err != nil {
1508 t.Fatal(err)
1509 }
1510
1511 cfg, err := LoadForRoot(project)
1512 if err != nil {
1513 t.Fatalf("LoadForRoot() error = %v", err)
1514 }
1515 if cfg.Secrets.FilterSubprocessEnv {
1516 t.Error("project reasonix.toml enabled filter_subprocess_env; [secrets] must stay user-global")
1517 }
1518 if cfg.Secrets.ProtectSensitiveFiles {
1519 t.Error("project reasonix.toml enabled protect_sensitive_files; [secrets] must stay user-global")
1520 }
1521 }
1522
1523 // TestRenderTOMLPersistsSecretsSection pins config-save round-tripping: the
1524 // renderer must emit [secrets] for the user scope or every WriteFile would
1525 // silently drop the user's security toggles.
1526 func TestRenderTOMLPersistsSecretsSection(t *testing.T) {
1527 cfg := Default()
1528 cfg.Secrets.FilterSubprocessEnv = true
1529 cfg.Secrets.ProtectSensitiveFiles = true
1530
1531 out := RenderTOMLForScope(cfg, RenderScopeUser)
1532 for _, want := range []string{"[secrets]", "filter_subprocess_env = true", "protect_sensitive_files = true"} {
1533 if !strings.Contains(out, want) {
1534 t.Fatalf("user-scope render missing %q:\n%s", want, out)
1535 }
1536 }
1537
1538 path := filepath.Join(t.TempDir(), "config.toml")
1539 if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
1540 t.Fatal(err)
1541 }
1542 back := Default()
1543 if err := mergeFile(back, path); err != nil {
1544 t.Fatalf("round-trip decode: %v", err)
1545 }
1546 if !back.Secrets.FilterSubprocessEnv || !back.Secrets.ProtectSensitiveFiles {
1547 t.Fatalf("secrets toggles lost in render round-trip: %+v", back.Secrets)
1548 }
1549
1550 // Project scope must not render the section — LoadForRoot ignores it there.
1551 if proj := RenderTOMLForScope(cfg, RenderScopeProject); strings.Contains(proj, "[secrets]") {
1552 t.Fatalf("project scope rendered [secrets]:\n%s", proj)
1553 }
1554 if strings.Contains(out, "redact_tool_output") {
1555 t.Fatalf("user-scope render still exposes removed live-redaction setting:\n%s", out)
1556 }
1557 }
1558
1558 lines GO