返回 DeepSeek-Reasonix
edit.go
根目录 / internal / config / edit.go
1 package config
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "math"
8 "os"
9 "path/filepath"
10 "regexp"
11 "runtime"
12 "slices"
13 "strings"
14
15 "github.com/BurntSushi/toml"
16
17 "reasonix/internal/extension/protocol"
18 "reasonix/internal/fileutil"
19 fileencoding "reasonix/internal/fileutil/encoding"
20 "reasonix/internal/mcpdiag"
21 "reasonix/internal/netclient"
22 "reasonix/internal/permission"
23 "reasonix/internal/permissionpreset"
24 )
25
26 var validDesktopExternalOpenerID = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
27
28 // edit.go is the programmatic mutation surface a settings UI drives: change the
29 // default model, add/remove a provider, set the planner, edit permission rules,
30 // add/remove an MCP server — each validated, then persisted with SaveTo. It is
31 // separate from the `reasonix setup` wizard (cli) so a GUI can apply one setting at a
32 // time without replaying the whole interactive flow. Every mutator works on the
33 // in-memory *Config; nothing writes to disk until SaveTo/Save is called, so a UI
34 // can stage several changes and commit once. Mutations round-trip through
35 // RenderTOML → Load (the wizard relies on the same guarantee).
36
37 // permission rule list names accepted by the rule mutators.
38 const (
39 listAllow = "allow"
40 listAsk = "ask"
41 listDeny = "deny"
42
43 // CompactRatioMin and CompactRatioMax are the bounds shared by the
44 // programmatic config editor and all CLI/Desktop callers.
45 CompactRatioMin = 0.30
46 CompactRatioMax = 0.85
47 )
48
49 // SetDefaultModel points default_model at an existing model. It accepts both
50 // forms used by the runtime resolver:
51 // - "provider" — the provider's own default model;
52 // - "provider/model" — that specific model under that provider.
53 //
54 // Either is rejected when the target does not exist, so a UI can't strand
55 // the config on a model that doesn't exist. Plugin-namespaced refs
56 // (plugin/<plugin>/<provider>/<model>) are the exception: they belong to
57 // extension sidecars, so the config catalog cannot vouch for them — boot's
58 // merged resolver gates them at the next launch instead.
59 func (c *Config) SetDefaultModel(name string) error {
60 name = strings.TrimSpace(name)
61 if name == "" {
62 return fmt.Errorf("set default: empty name")
63 }
64 if _, ok := c.ResolveModel(name); !ok && protocol.PluginRefOwner(name) == "" {
65 return fmt.Errorf("set default: no such model %q (configured: %s)", name, c.providerNames())
66 }
67 c.DefaultModel = name
68 return nil
69 }
70
71 // SetPlannerModel sets (or, with "", clears) agent.planner_model for two-model
72 // collaboration. A non-empty name must be a configured provider.
73 func (c *Config) SetPlannerModel(name string) error {
74 if name == "" {
75 c.Agent.PlannerModel = ""
76 return nil
77 }
78 if _, ok := c.Provider(name); !ok {
79 return fmt.Errorf("set planner: no provider %q (configured: %s)", name, c.providerNames())
80 }
81 c.Agent.PlannerModel = name
82 return nil
83 }
84
85 // SetVisionModel sets (or clears) the optional image-understanding fallback.
86 // "auto" is resolved by the runtime within the active provider; an explicit
87 // value must be a configured vision-capable model.
88 func (c *Config) SetVisionModel(name string) error {
89 name = strings.TrimSpace(name)
90 if name == "" {
91 c.Agent.VisionModel = ""
92 return nil
93 }
94 if strings.EqualFold(name, "auto") {
95 c.Agent.VisionModel = "auto"
96 return nil
97 }
98 entry, ok := c.ResolveModel(name)
99 if !ok {
100 return fmt.Errorf("set vision model: no such model %q (configured: %s)", name, c.providerNames())
101 }
102 if NewModelCapabilityResolver().Resolve(entry).State != CapabilitySupported {
103 return fmt.Errorf("set vision model: %q does not support image input", name)
104 }
105 if !entry.Configured() {
106 return fmt.Errorf("set vision model: provider %q has no key", entry.Name)
107 }
108 c.Agent.VisionModel = entry.Name + "/" + entry.Model
109 return nil
110 }
111
112 // SetAutoPlan is retained for source compatibility with older desktop clients.
113 // Automatic plan mode is retired: "off" is an idempotent compatibility write,
114 // while every attempt to enable it is rejected explicitly.
115 func (c *Config) SetAutoPlan(mode string) error {
116 if strings.EqualFold(strings.TrimSpace(mode), "off") {
117 c.Agent.AutoPlan = "off"
118 c.Agent.AutoPlanClassifier = ""
119 return nil
120 }
121 return fmt.Errorf("automatic plan mode has been retired; use Plan Mode explicitly")
122 }
123
124 // SetDesktopDefaultToolApprovalMode sets the execution permission preset used
125 // only for newly-created desktop sessions. Legacy names remain accepted at
126 // this compatibility boundary.
127 func (c *Config) SetDesktopDefaultToolApprovalMode(mode string) error {
128 raw := strings.ToLower(strings.TrimSpace(mode))
129 switch raw {
130 case "ask", "auto", "yolo", "full", "full-access", "danger_full_access", string(permissionpreset.ReadOnly), string(permissionpreset.WorkspaceWrite), string(permissionpreset.DangerFullAccess):
131 c.Desktop.DefaultToolApprovalMode = string(permissionpreset.Normalize(raw))
132 default:
133 return fmt.Errorf("default_tool_approval_mode %q: must be read-only|workspace-write|danger-full-access", mode)
134 }
135 return nil
136 }
137
138 // SetUIShortcutLayout selects the CLI keyboard shortcut layout. "classic" keeps
139 // historical behavior; "desktop" enables the two-axis desktop-style shortcuts.
140 func (c *Config) SetUIShortcutLayout(layout string) error {
141 switch strings.ToLower(strings.TrimSpace(layout)) {
142 case "", "classic", "default", "legacy", "off":
143 c.UI.ShortcutLayout = "classic"
144 case "desktop", "dual", "dual-axis", "dual_axis":
145 c.UI.ShortcutLayout = "desktop"
146 default:
147 return fmt.Errorf("shortcut_layout %q: must be classic|desktop", layout)
148 }
149 return nil
150 }
151
152 // UpsertProvider adds e, or replaces an existing provider with the same name
153 // (preserving its position). Required fields (name, kind, base_url, model/models)
154 // are validated; whether the kind is actually registered and the key resolves is
155 // checked later by provider.New / Validate, which give actionable errors.
156 func (c *Config) UpsertProvider(e ProviderEntry) error {
157 normalizeProviderEffortFields(&e)
158 if err := validateProvider(e); err != nil {
159 return err
160 }
161 for i := range c.Providers {
162 if c.Providers[i].Name == e.Name {
163 c.Providers[i] = e
164 return nil
165 }
166 }
167 c.Providers = append(c.Providers, e)
168 return nil
169 }
170
171 // UpsertProviderPreservingRuntime applies persisted provider fields while
172 // retaining process-only state derived by the latest config load. It is used
173 // when replaying an optimistic edit log onto fresh state.
174 func (c *Config) UpsertProviderPreservingRuntime(e ProviderEntry) error {
175 if current, ok := c.Provider(e.Name); ok {
176 e.persistedOfficialCurrency = current.persistedOfficialCurrency
177 if strings.TrimSpace(current.APIKeyEnv) == strings.TrimSpace(e.APIKeyEnv) {
178 e.resolvedAPIKey = current.resolvedAPIKey
179 e.resolvedSource = current.resolvedSource
180 e.visionOverride = current.visionOverride
181 }
182 }
183 return c.UpsertProvider(e)
184 }
185
186 // SetProviderEffort updates a provider's provider-specific thinking effort knob.
187 func (c *Config) SetProviderEffort(name, effort string) error {
188 for i := range c.Providers {
189 if c.Providers[i].Name == name {
190 c.Providers[i].Effort = normalizeStoredEffort(effort)
191 return nil
192 }
193 }
194 return fmt.Errorf("set provider effort: no provider %q", name)
195 }
196
197 // SetLanguage pins the CLI UI/model language; empty/auto clears the override so runtime detection falls back to REASONIX_LANG / locale.
198 func (c *Config) SetLanguage(lang string) error {
199 switch strings.ToLower(strings.TrimSpace(lang)) {
200 case "", "auto":
201 c.Language = ""
202 case "en":
203 c.Language = "en"
204 case "zh":
205 c.Language = "zh"
206 default:
207 return fmt.Errorf("language %q: must be auto|en|zh", lang)
208 }
209 c.ApplyDeepSeekOfficialDefaultPricing()
210 return nil
211 }
212
213 // SetReasoningLanguage pins the preferred language for visible reasoning text.
214 // Empty/auto follows the conversation language.
215 func (c *Config) SetReasoningLanguage(lang string) error {
216 switch strings.ToLower(strings.TrimSpace(lang)) {
217 case "", "auto", "follow", "conversation", "detect", "default", "model", "model-default", "model_default", "provider":
218 c.Agent.ReasoningLanguage = ""
219 case "zh", "cn", "chinese", "中文":
220 c.Agent.ReasoningLanguage = "zh"
221 case "en", "english":
222 c.Agent.ReasoningLanguage = "en"
223 default:
224 return fmt.Errorf("reasoning language %q: must be auto|zh|en", lang)
225 }
226 return nil
227 }
228
229 // SetDesktopLanguage pins the desktop UI language. It intentionally does not
230 // modify Config.Language, which is used by the CLI/model-facing runtime.
231 func (c *Config) SetDesktopLanguage(lang string) error {
232 switch strings.ToLower(strings.TrimSpace(lang)) {
233 case "", "auto":
234 c.Desktop.Language = ""
235 case "en":
236 c.Desktop.Language = "en"
237 case "zh":
238 c.Desktop.Language = "zh"
239 default:
240 return fmt.Errorf("desktop language %q: must be auto|en|zh", lang)
241 }
242 c.ApplyDeepSeekOfficialDefaultPricing()
243 return nil
244 }
245
246 // SetDesktopAppearance sets desktop-only theme preferences. It must not affect
247 // CLI theme settings or provider-visible request data.
248 func (c *Config) SetDesktopAppearance(theme, style string) error {
249 switch strings.ToLower(strings.TrimSpace(theme)) {
250 case "auto":
251 c.Desktop.Theme = "auto"
252 case "light":
253 c.Desktop.Theme = "light"
254 case "", "dark":
255 c.Desktop.Theme = "dark"
256 default:
257 return fmt.Errorf("desktop theme %q: must be auto|dark|light", theme)
258 }
259 if strings.TrimSpace(style) == "" {
260 c.Desktop.ThemeStyle = ""
261 return nil
262 }
263 normalized := normalizeThemeStyle(style)
264 if normalized == "" {
265 return fmt.Errorf("desktop theme style %q: must be graphite|aurora|slate|carbon|nocturne|amber", style)
266 }
267 c.Desktop.ThemeStyle = normalized
268 return nil
269 }
270
271 // SetDesktopTerminalTheme sets the integrated terminal colour preference.
272 // This is desktop-only UI state and never rebuilds or changes model requests.
273 func (c *Config) SetDesktopTerminalTheme(theme string) error {
274 switch strings.ToLower(strings.TrimSpace(theme)) {
275 case "", "auto":
276 c.Desktop.TerminalTheme = "auto"
277 case "dark":
278 c.Desktop.TerminalTheme = "dark"
279 case "light":
280 c.Desktop.TerminalTheme = "light"
281 default:
282 return fmt.Errorf("desktop terminal theme %q: must be auto|dark|light", theme)
283 }
284 return nil
285 }
286
287 // SetDesktopLayoutStyle sets the desktop layout style. UI-only; it must not
288 // affect CLI output or provider-visible request data.
289 func (c *Config) SetDesktopLayoutStyle(style string) error {
290 switch strings.ToLower(strings.TrimSpace(style)) {
291 case "creation":
292 c.Desktop.LayoutStyle = "creation"
293 case "", "classic", "workbench", "workspace":
294 // "classic" is retired and stores as workbench, matching the read path
295 // in normalizeDesktopLayoutStyle. An older caller that still sends it
296 // gets the surviving style rather than an error.
297 c.Desktop.LayoutStyle = "workbench"
298 default:
299 return fmt.Errorf("desktop layout style %q: must be workbench|creation", style)
300 }
301 return nil
302 }
303
304 // SetDesktopExternalOpener stores the stable id selected by the desktop Open
305 // control. Availability is deliberately checked by the native desktop shell,
306 // because config is shared across operating systems and installations.
307 func (c *Config) SetDesktopExternalOpener(id string) error {
308 id = strings.ToLower(strings.TrimSpace(id))
309 if id == "" {
310 c.Desktop.ExternalOpener = ""
311 return nil
312 }
313 if !validDesktopExternalOpenerID.MatchString(id) {
314 return fmt.Errorf("external opener %q: invalid id", id)
315 }
316 c.Desktop.ExternalOpener = id
317 return nil
318 }
319
320 // SetDesktopCloseBehavior sets the desktop close-window preference. It is
321 // intentionally UI-only and must not affect model prompts or provider-visible
322 // request data.
323 func (c *Config) SetDesktopCloseBehavior(mode string) error {
324 switch strings.ToLower(strings.TrimSpace(mode)) {
325 case "quit", "exit":
326 c.Desktop.CloseBehavior = "quit"
327 case "", "background", "hide":
328 c.Desktop.CloseBehavior = "background"
329 default:
330 return fmt.Errorf("close behavior %q: must be quit|background", mode)
331 }
332 return nil
333 }
334
335 // SetDesktopDisplayMode sets the transcript display mode. UI-only.
336 func (c *Config) SetDesktopDisplayMode(mode string) error {
337 switch strings.ToLower(strings.TrimSpace(mode)) {
338 case "compact", "minimal":
339 c.Desktop.DisplayMode = "compact"
340 case "", "standard":
341 c.Desktop.DisplayMode = "standard"
342 default:
343 return fmt.Errorf("display mode %q: must be standard|compact", mode)
344 }
345 return nil
346 }
347
348 // SetDesktopStatusBarStyle sets the desktop status bar metric label style.
349 // UI-only; it must not affect CLI output or provider-visible request data.
350 func (c *Config) SetDesktopStatusBarStyle(style string) error {
351 switch strings.ToLower(strings.TrimSpace(style)) {
352 case "icon", "icons":
353 c.Desktop.StatusBarStyle = "icon"
354 case "", "text", "label", "labels":
355 c.Desktop.StatusBarStyle = "text"
356 default:
357 return fmt.Errorf("status bar style %q: must be icon|text", style)
358 }
359 c.Desktop.StatusBarStyleInitialized = true
360 return nil
361 }
362
363 // SetDesktopStatusBarItems sets the ordered visible desktop status bar items.
364 // UI-only; it must not affect CLI output or provider-visible request data.
365 func (c *Config) SetDesktopStatusBarItems(items []string) error {
366 out := make([]string, 0, len(items))
367 seen := map[string]bool{}
368 for _, raw := range items {
369 id := strings.TrimSpace(raw)
370 if id == "" || seen[id] {
371 continue
372 }
373 if !knownDesktopStatusBarItems[id] {
374 return fmt.Errorf("status bar item %q: unknown item", id)
375 }
376 out = append(out, id)
377 seen[id] = true
378 }
379 if len(out) == 0 {
380 out = DefaultDesktopStatusBarItems()
381 }
382 c.Desktop.StatusBarItems = out
383 return nil
384 }
385
386 // SetDesktopCheckUpdates sets whether the desktop app checks for updates on
387 // startup. Manual checks remain available in Settings regardless of this value.
388 func (c *Config) SetDesktopCheckUpdates(enabled bool) error {
389 c.Desktop.CheckUpdates = &enabled
390 return nil
391 }
392
393 // SetDesktopUpdateChannel is retained for pre-single-channel desktop clients.
394 // Clearing the legacy field keeps the next canonical write channel-free.
395 func (c *Config) SetDesktopUpdateChannel(_ string) error {
396 c.Desktop.UpdateChannel = ""
397 return nil
398 }
399
400 // SetCLIUpdateChannel is retained for older CLI scripts. Every recognized
401 // historical value migrates to the official channel and is omitted on save.
402 func (c *Config) SetCLIUpdateChannel(channel string) error {
403 switch strings.ToLower(strings.TrimSpace(channel)) {
404 case "", "stable", "preview", "canary", "beta", "next":
405 c.CLI.UpdateChannel = ""
406 default:
407 return fmt.Errorf("CLI update channel %q is unsupported; Reasonix now uses the official release channel", channel)
408 }
409 return nil
410 }
411
412 // SetColdResumePrune toggles auto-elision of stale tool results on cold resume.
413 func (c *Config) SetColdResumePrune(enabled bool) error {
414 c.Agent.ColdResumePrune = &enabled
415 return nil
416 }
417
418 // SetCompactRatio updates the sole user-controlled automatic compaction
419 // threshold. Allowed range is CompactRatioMin–CompactRatioMax; presets are
420 // 0.70 / 0.80 / 0.85.
421 func (c *Config) SetCompactRatio(ratio float64) error {
422 if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio < CompactRatioMin || ratio > CompactRatioMax {
423 return fmt.Errorf("compact ratio %v: must be between %.2f and %.2f", ratio, CompactRatioMin, CompactRatioMax)
424 }
425 c.Agent.CompactRatio = ratio
426 return nil
427 }
428
429 // SetDesktopTelemetry sets whether the desktop sends the anonymous launch ping.
430 func (c *Config) SetDesktopTelemetry(enabled bool) error {
431 c.Desktop.Telemetry = &enabled
432 return nil
433 }
434
435 // SetDesktopMetrics sets whether the desktop sends aggregate desktop metrics.
436 func (c *Config) SetDesktopMetrics(enabled bool) error {
437 c.Desktop.Metrics = &enabled
438 return nil
439 }
440
441 // SetCLITelemetryMode sets the user-global content-free CLI metrics policy.
442 func (c *Config) SetCLITelemetryMode(mode string) error {
443 switch strings.ToLower(strings.TrimSpace(mode)) {
444 case "", "auto":
445 c.Telemetry.CLIMetrics = "auto"
446 case "on":
447 c.Telemetry.CLIMetrics = "on"
448 case "off":
449 c.Telemetry.CLIMetrics = "off"
450 default:
451 return fmt.Errorf("cli_metrics %q: must be auto|on|off", mode)
452 }
453 return nil
454 }
455
456 // SetDesktopConversationWidth sets the max transcript width preference.
457 // standard = 960px fixed; full = 90% of the parent, with a 960px floor.
458 // An empty value resets to standard.
459 func (c *Config) SetDesktopConversationWidth(width string) error {
460 switch strings.ToLower(strings.TrimSpace(width)) {
461 case "", "standard":
462 c.Desktop.ConversationWidth = "standard"
463 case "full":
464 c.Desktop.ConversationWidth = "full"
465 default:
466 return fmt.Errorf("conversation width %q: must be standard|full", width)
467 }
468 return nil
469 }
470
471 // SetUICloseBehavior is kept for callers compiled against the old edit API.
472 func (c *Config) SetUICloseBehavior(mode string) error {
473 return c.SetDesktopCloseBehavior(mode)
474 }
475
476 // SetShowReasoning sets the CLI's default verbose-reasoning preference. When
477 // true, thinking text is shown in the chat TUI on startup; when false (the
478 // default), it stays collapsed until the user toggles it with Ctrl+O or
479 // /verbose.
480 func (c *Config) SetShowReasoning(on bool) error {
481 c.UI.ShowReasoning = on
482 return nil
483 }
484
485 // SetProviderThinking updates a provider's provider-specific thinking mode knob.
486 func (c *Config) SetProviderThinking(name, thinking string) error {
487 for i := range c.Providers {
488 if c.Providers[i].Name == name {
489 c.Providers[i].Thinking = strings.ToLower(strings.TrimSpace(thinking))
490 return nil
491 }
492 }
493 return fmt.Errorf("set provider thinking: no provider %q", name)
494 }
495
496 // SetNetwork updates ordinary outbound network proxy settings. Invalid custom
497 // proxy settings are rejected here so the desktop panel cannot save a config that
498 // would break provider startup.
499 func (c *Config) SetNetwork(n NetworkConfig) error {
500 n.ProxyMode = netclient.NormalizeMode(n.ProxyMode)
501 n.ProxyURL = strings.TrimSpace(n.ProxyURL)
502 n.NoProxy = strings.TrimSpace(n.NoProxy)
503 n.Proxy.Type = strings.ToLower(strings.TrimSpace(n.Proxy.Type))
504 n.Proxy.Server = strings.TrimSpace(n.Proxy.Server)
505 n.Proxy.Username = strings.TrimSpace(n.Proxy.Username)
506 c.Network = n
507 return netclient.Validate(c.NetworkProxySpec())
508 }
509
510 // ModelRefsProvider reports whether ref targets the named provider. It matches
511 // both bare provider names ("deepseek") and "provider/model" refs.
512 func ModelRefsProvider(ref, name string) bool {
513 ref = strings.TrimSpace(ref)
514 name = strings.TrimSpace(name)
515 if ref == "" || name == "" {
516 return false
517 }
518 if ref == name {
519 return true
520 }
521 prov, _, ok := strings.Cut(ref, "/")
522 return ok && prov == name
523 }
524
525 func (c *Config) modelRefTargetsProvider(ref, name string) bool {
526 if ModelRefsProvider(ref, name) {
527 return true
528 }
529 if e, ok := c.ResolveModel(ref); ok {
530 return e.Name == name
531 }
532 return false
533 }
534
535 // RemoveProvider deletes the named provider. References to the removed provider
536 // are migrated to the first remaining configured provider when possible. The
537 // default model is required, so removal is refused when no fallback exists;
538 // optional planner/subagent refs are cleared instead of being left dangling.
539 func (c *Config) RemoveProvider(name string) error {
540 name = strings.TrimSpace(name)
541 idx := -1
542 for i := range c.Providers {
543 if c.Providers[i].Name == name {
544 idx = i
545 break
546 }
547 }
548 if idx < 0 {
549 return fmt.Errorf("remove provider: no provider %q", name)
550 }
551
552 defaultRefsProvider := c.modelRefTargetsProvider(c.DefaultModel, name)
553 plannerRefsProvider := c.modelRefTargetsProvider(c.Agent.PlannerModel, name)
554 subagentRefsProvider := c.modelRefTargetsProvider(c.Agent.SubagentModel, name)
555 subagentModelRefsProvider := map[string]bool{}
556 for skill, ref := range c.Agent.SubagentModels {
557 if c.modelRefTargetsProvider(ref, name) {
558 subagentModelRefsProvider[skill] = true
559 }
560 }
561
562 fallback := ""
563 visionRefsProvider := c.modelRefTargetsProvider(c.Agent.VisionModel, name)
564 if defaultRefsProvider || plannerRefsProvider || visionRefsProvider || subagentRefsProvider || len(subagentModelRefsProvider) > 0 {
565 fallback = c.providerRemovalFallback(name)
566 }
567 if defaultRefsProvider && fallback == "" {
568 return fmt.Errorf("remove provider: %q is referenced by default_model and no other configured provider exists", name)
569 }
570
571 c.Providers = append(c.Providers[:idx], c.Providers[idx+1:]...)
572
573 if defaultRefsProvider {
574 c.DefaultModel = fallback
575 }
576 if plannerRefsProvider {
577 c.Agent.PlannerModel = fallback
578 }
579 if visionRefsProvider {
580 c.Agent.VisionModel = ""
581 }
582 if subagentRefsProvider {
583 c.Agent.SubagentModel = fallback
584 }
585 for skill := range subagentModelRefsProvider {
586 if fallback != "" {
587 c.Agent.SubagentModels[skill] = fallback
588 } else {
589 delete(c.Agent.SubagentModels, skill)
590 }
591 }
592 return nil
593 }
594
595 func (c *Config) providerRemovalFallback(name string) string {
596 for i := range c.Providers {
597 p := &c.Providers[i]
598 if p.Name == name || !p.Configured() || len(p.ModelList()) == 0 {
599 continue
600 }
601 return p.Name
602 }
603 return ""
604 }
605
606 // validateProvider checks the fields a provider can't function without.
607 func validateProvider(e ProviderEntry) error {
608 switch {
609 case strings.TrimSpace(e.Name) == "":
610 return fmt.Errorf("provider: name is required")
611 case strings.TrimSpace(e.Kind) == "":
612 return fmt.Errorf("provider %q: kind is required", e.Name)
613 case strings.TrimSpace(e.BaseURL) == "":
614 return fmt.Errorf("provider %q: base_url is required", e.Name)
615 case !providerHasAnyModel(e):
616 return fmt.Errorf("provider %q: model is required", e.Name)
617 case strings.TrimSpace(e.APIKeyEnv) != "" && !IsValidCredentialKey(e.APIKeyEnv):
618 return fmt.Errorf("provider %q: api_key_env %q is not a valid environment variable name", e.Name, e.APIKeyEnv)
619 }
620 return nil
621 }
622
623 func providerHasAnyModel(e ProviderEntry) bool {
624 if strings.TrimSpace(e.Model) != "" {
625 return true
626 }
627 for _, m := range e.Models {
628 if strings.TrimSpace(m) != "" {
629 return true
630 }
631 }
632 return false
633 }
634
635 // SetPermissionMode sets the writer-fallback mode. Accepts "ask", "allow", or
636 // "deny" (case-insensitive); anything else errors rather than silently
637 // defaulting, so a UI surfaces a typo instead of installing a surprising mode.
638 func (c *Config) SetPermissionMode(mode string) error {
639 switch strings.ToLower(strings.TrimSpace(mode)) {
640 case "ask", "allow", "deny":
641 c.Permissions.Mode = strings.ToLower(strings.TrimSpace(mode))
642 return nil
643 default:
644 return fmt.Errorf("permission mode %q: must be ask|allow|deny", mode)
645 }
646 }
647
648 // AddPermissionRule appends a rule ("ToolName" or "ToolName(glob)") to the
649 // allow / ask / deny list. The rule is validated with the same parser the gate
650 // uses, and a duplicate is a no-op so a UI can call it idempotently.
651 func (c *Config) AddPermissionRule(list, rule string) error {
652 target, err := c.ruleList(list)
653 if err != nil {
654 return err
655 }
656 rule = strings.TrimSpace(rule)
657 if _, ok := permission.ParseRule(rule); !ok {
658 return fmt.Errorf("invalid permission rule %q (want \"ToolName\" or \"ToolName(glob)\")", rule)
659 }
660 if slices.Contains(*target, rule) {
661 return nil // already present
662 }
663 *target = append(*target, rule)
664 return nil
665 }
666
667 // RemovePermissionRule drops the first exact match of rule from the named list,
668 // reporting whether anything was removed.
669 func (c *Config) RemovePermissionRule(list, rule string) (bool, error) {
670 target, err := c.ruleList(list)
671 if err != nil {
672 return false, err
673 }
674 rule = strings.TrimSpace(rule)
675 for i, existing := range *target {
676 if existing == rule {
677 *target = append((*target)[:i], (*target)[i+1:]...)
678 return true, nil
679 }
680 }
681 return false, nil
682 }
683
684 // ruleList returns a pointer to the named rule slice so mutators can append to
685 // it in place. An unknown list name errors.
686 func (c *Config) ruleList(list string) (*[]string, error) {
687 switch strings.ToLower(strings.TrimSpace(list)) {
688 case listAllow:
689 return &c.Permissions.Allow, nil
690 case listAsk:
691 return &c.Permissions.Ask, nil
692 case listDeny:
693 return &c.Permissions.Deny, nil
694 default:
695 return nil, fmt.Errorf("unknown permission list %q (want allow|ask|deny)", list)
696 }
697 }
698
699 // AddSkillPath appends a custom skill root, deduping by its expanded absolute
700 // path while preserving the caller's original spelling in the config file.
701 func (c *Config) AddSkillPath(path string) error {
702 path = strings.TrimSpace(path)
703 if path == "" {
704 return fmt.Errorf("skill path: empty path")
705 }
706 want := CanonicalSkillPath(path)
707 c.removeExcludedSkillPath(want)
708 for _, existing := range c.Skills.Paths {
709 if CanonicalSkillPath(existing) == want {
710 return nil
711 }
712 }
713 c.Skills.Paths = append(c.Skills.Paths, path)
714 return nil
715 }
716
717 // RemoveSkillPath removes the first custom skill root matching path after
718 // expansion and path cleaning. It reports whether anything changed.
719 func (c *Config) RemoveSkillPath(path string) (bool, error) {
720 path = strings.TrimSpace(path)
721 if path == "" {
722 return false, fmt.Errorf("skill path: empty path")
723 }
724 want := CanonicalSkillPath(path)
725 for i, existing := range c.Skills.Paths {
726 if CanonicalSkillPath(existing) == want {
727 c.Skills.Paths = append(c.Skills.Paths[:i], c.Skills.Paths[i+1:]...)
728 return true, nil
729 }
730 }
731 return false, nil
732 }
733
734 // RestoreSkillPath removes a pseudo-deleted skill source from excluded_paths.
735 func (c *Config) RestoreSkillPath(path string) error {
736 path = strings.TrimSpace(path)
737 if path == "" {
738 return fmt.Errorf("skill path: empty path")
739 }
740 want := CanonicalSkillPath(path)
741 if want == "" {
742 return fmt.Errorf("skill path: empty path")
743 }
744 c.removeExcludedSkillPath(want)
745 return nil
746 }
747
748 // ExcludeSkillPath hides any skill discovery root matching path. This is used by
749 // UI "remove source" actions for convention roots that are not stored in paths.
750 func (c *Config) ExcludeSkillPath(path string) error {
751 path = strings.TrimSpace(path)
752 if path == "" {
753 return fmt.Errorf("skill path: empty path")
754 }
755 want := CanonicalSkillPath(path)
756 if want == "" {
757 return fmt.Errorf("skill path: empty path")
758 }
759 for _, existing := range c.Skills.ExcludedPaths {
760 if CanonicalSkillPath(existing) == want {
761 return nil
762 }
763 }
764 c.Skills.ExcludedPaths = append(c.Skills.ExcludedPaths, path)
765 return nil
766 }
767
768 // SetSkillPathEnabled enables or disables a skill discovery root without
769 // deleting its configured path. Disabled roots are recorded in excluded_paths
770 // and can be restored without asking the user to browse for the folder again.
771 func (c *Config) SetSkillPathEnabled(path string, enabled bool) error {
772 path = strings.TrimSpace(path)
773 if path == "" {
774 return fmt.Errorf("skill path: empty path")
775 }
776 want := CanonicalSkillPath(path)
777 if want == "" {
778 return fmt.Errorf("skill path: empty path")
779 }
780 if enabled {
781 c.removeExcludedSkillPath(want)
782 return nil
783 }
784 return c.ExcludeSkillPath(path)
785 }
786
787 func (c *Config) removeExcludedSkillPath(want string) {
788 next := c.Skills.ExcludedPaths[:0]
789 for _, existing := range c.Skills.ExcludedPaths {
790 if CanonicalSkillPath(existing) != want {
791 next = append(next, existing)
792 }
793 }
794 c.Skills.ExcludedPaths = next
795 }
796
797 // SetSkillEnabled persists a per-skill enable/disable preference. Skills are
798 // enabled by default; disabling records the name, enabling removes it.
799 func (c *Config) SetSkillEnabled(name string, enabled bool) error {
800 name = strings.TrimSpace(name)
801 key := SkillNameKey(name)
802 if key == "" {
803 return fmt.Errorf("skill name %q: use letters, digits, '_', '-', '.', 1-64 chars, starting alphanumeric", name)
804 }
805 next := c.DisabledSkillNames()
806 idx := -1
807 for i, existing := range next {
808 if SkillNameKey(existing) == key {
809 idx = i
810 break
811 }
812 }
813 if enabled {
814 if idx >= 0 {
815 next = append(next[:idx], next[idx+1:]...)
816 }
817 c.Skills.DisabledSkills = next
818 return nil
819 }
820 if idx < 0 {
821 next = append(next, name)
822 }
823 c.Skills.DisabledSkills = next
824 return nil
825 }
826
827 // SetSkillImplicitInvocation controls whether skills are exposed to the model
828 // for automatic discovery and invocation. Explicit /skill commands remain
829 // available regardless of this setting.
830 func (c *Config) SetSkillImplicitInvocation(enabled bool) {
831 c.Skills.DisableImplicitInvocation = !enabled
832 }
833
834 // CanonicalSkillPath expands env vars, ~ and relative segments to an absolute
835 // cleaned path for comparing skill roots. On Windows it folds case so paths that
836 // differ only in casing dedupe. Use only for comparison, never as stored config.
837 func CanonicalSkillPath(path string) string {
838 path = ExpandVars(strings.TrimSpace(path))
839 if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
840 if home, err := os.UserHomeDir(); err == nil {
841 path = filepath.Join(home, path[2:])
842 }
843 } else if path == "~" {
844 if home, err := os.UserHomeDir(); err == nil {
845 path = home
846 }
847 }
848 if abs, err := filepath.Abs(path); err == nil {
849 path = abs
850 }
851 // Resolve existing paths before cleaning so Windows 8.3 short names and
852 // long names compare identically. A missing configured path still falls
853 // back to the absolute lexical form used for persistence and diagnostics.
854 if resolved, err := filepath.EvalSymlinks(path); err == nil {
855 path = resolved
856 }
857 path = filepath.Clean(path)
858 if runtime.GOOS == "windows" {
859 return strings.ToLower(path)
860 }
861 return path
862 }
863
864 // UpsertPlugin adds e, or replaces an MCP server with the same name (preserving
865 // position). The transport-specific required fields are validated: stdio needs
866 // a command, http/sse need a url.
867 func (c *Config) UpsertPlugin(e PluginEntry) error {
868 e, _ = NormalizePluginCommandLine(e)
869 if err := validatePlugin(e); err != nil {
870 return err
871 }
872 for i := range c.Plugins {
873 if c.Plugins[i].Name == e.Name {
874 c.Plugins[i] = e
875 return nil
876 }
877 }
878 c.Plugins = append(c.Plugins, e)
879 return nil
880 }
881
882 // RemovePlugin deletes the named MCP server, reporting whether it was present.
883 func (c *Config) RemovePlugin(name string) bool {
884 for i := range c.Plugins {
885 if c.Plugins[i].Name == name {
886 c.Plugins = append(c.Plugins[:i], c.Plugins[i+1:]...)
887 return true
888 }
889 }
890 return false
891 }
892
893 // ClearPluginAuthentication removes locally stored auth-like material for one
894 // MCP server while keeping the server entry itself. It intentionally leaves
895 // non-auth config (command, URL host/path, ordinary env/header keys, tier) alone.
896 func (c *Config) ClearPluginAuthentication(name string) (PluginEntry, bool, error) {
897 for i := range c.Plugins {
898 if c.Plugins[i].Name != name {
899 continue
900 }
901 headers, env, url, changed := mcpdiag.ClearAuthConfig(c.Plugins[i].Headers, c.Plugins[i].Env, c.Plugins[i].URL)
902 c.Plugins[i].Headers = headers
903 c.Plugins[i].Env = env
904 c.Plugins[i].URL = url
905 return c.Plugins[i], changed, nil
906 }
907 return PluginEntry{}, false, fmt.Errorf("clear plugin authentication: no plugin %q", name)
908 }
909
910 // ClearPluginAuthenticationInSource clears auth material in the file that actually
911 // owns the MCP server. Load() merges user/project TOML and project .mcp.json into
912 // one Config, so callers must not mutate that merged view and Save() it back: a
913 // .mcp.json-only server would otherwise be serialized into reasonix.toml or the
914 // user config. Source priority mirrors Load(): project TOML, user TOML, then the
915 // project .mcp.json entry if TOML did not define that server.
916 func ClearPluginAuthenticationInSource(name string) (PluginEntry, bool, string, error) {
917 return ClearPluginAuthenticationInSourceForRoot(".", name)
918 }
919
920 // ClearPluginAuthenticationInSourceForRoot clears auth material in the source
921 // that owns name for the supplied workspace. The root is explicit so a desktop
922 // action cannot drift to another project's reasonix.toml or .mcp.json after the
923 // user switches tabs while the action is waiting on a lifecycle lock.
924 func ClearPluginAuthenticationInSourceForRoot(root, name string) (PluginEntry, bool, string, error) {
925 resolvedRoot := resolveRoot(root)
926 projectTOML := "reasonix.toml"
927 projectMCPJSON := mcpJSONFile
928 if resolvedRoot != "." {
929 projectTOML = filepath.Join(resolvedRoot, "reasonix.toml")
930 projectMCPJSON = filepath.Join(resolvedRoot, mcpJSONFile)
931 }
932 lockPaths := append([]string{}, userConfigCandidatePaths()...)
933 lockPaths = append(lockPaths, projectTOML, projectMCPJSON)
934 if legacy := legacyConfigPath(); strings.TrimSpace(legacy) != "" {
935 lockPaths = append(lockPaths, legacy)
936 }
937 unlock, err := lockConfigFilesEdits(lockPaths...)
938 if err != nil {
939 return PluginEntry{}, false, "", fmt.Errorf("clear plugin authentication: %w", err)
940 }
941 defer unlock()
942
943 cfg, err := LoadForRootReadOnly(root)
944 if err != nil {
945 return PluginEntry{}, false, "", err
946 }
947 entry, found := pluginEntryByName(cfg.Plugins, strings.TrimSpace(name))
948 if !found {
949 return PluginEntry{}, false, "", fmt.Errorf("clear plugin authentication: no plugin %q", name)
950 }
951 path := MCPConfigPathForEntry(root, entry)
952 if entry.Source != MCPSourceProjectMCPJSON {
953 cfg, err := LoadForEditReadOnlyStrict(path)
954 if err != nil {
955 return PluginEntry{}, false, path, err
956 }
957 updated, changed, err := cfg.ClearPluginAuthentication(name)
958 if err != nil {
959 return PluginEntry{}, false, path, err
960 }
961 if changed {
962 if err := cfg.SaveTo(path); err != nil {
963 return PluginEntry{}, false, path, err
964 }
965 }
966 return updated, changed, path, nil
967 }
968 updated, changed, err := clearMCPJSONAuthentication(path, name)
969 if err != nil {
970 return PluginEntry{}, false, "", err
971 }
972 return updated, changed, path, nil
973 }
974
975 func pluginTOMLSourcePathForRoot(root, name string) string {
976 projectTOML := "reasonix.toml"
977 if resolved := resolveRoot(root); resolved != "." {
978 projectTOML = filepath.Join(resolved, "reasonix.toml")
979 }
980 paths := append([]string{projectTOML}, userConfigCandidatePaths()...)
981 for _, path := range paths {
982 if strings.TrimSpace(path) == "" {
983 continue
984 }
985 cfg := LoadForEdit(path)
986 for _, p := range cfg.Plugins {
987 if p.Name == name {
988 return path
989 }
990 }
991 }
992 return ""
993 }
994
995 // MCPConfigPathForEntry returns the writable config file that owns entry.
996 // Runtime configuration is merged by name, so callers must use provenance
997 // instead of saving the merged Config back to whichever file happens to have
998 // the highest priority.
999 func MCPConfigPathForEntry(root string, entry PluginEntry) string {
1000 resolvedRoot := resolveRoot(root)
1001 projectTOML := "reasonix.toml"
1002 projectMCPJSON := mcpJSONFile
1003 if resolvedRoot != "." {
1004 projectTOML = filepath.Join(resolvedRoot, "reasonix.toml")
1005 projectMCPJSON = filepath.Join(resolvedRoot, mcpJSONFile)
1006 }
1007 switch entry.Source {
1008 case MCPSourceProjectConfig:
1009 return projectTOML
1010 case MCPSourceProjectMCPJSON:
1011 return projectMCPJSON
1012 case MCPSourceUserConfig:
1013 for _, path := range userConfigCandidatePaths() {
1014 cfg := LoadForEditWithoutCredentials(path)
1015 if _, ok := pluginEntryByName(cfg.Plugins, entry.Name); ok {
1016 return path
1017 }
1018 }
1019 return UserConfigPath()
1020 case MCPSourceLegacyUser:
1021 return legacyConfigPath()
1022 case MCPSourcePluginPackage:
1023 return ""
1024 }
1025 if path := pluginTOMLSourcePathForRoot(root, entry.Name); path != "" {
1026 return path
1027 }
1028 if _, found, err := LoadMCPJSONPlugin(projectMCPJSON, entry.Name); err == nil && found {
1029 return projectMCPJSON
1030 }
1031 return UserConfigPath()
1032 }
1033
1034 // UpsertPluginInSourceForRoot writes entry back to its owning scope. New and
1035 // legacy user entries are normalized into the current user-global config;
1036 // project entries remain in their original project file.
1037 func UpsertPluginInSourceForRoot(root string, entry PluginEntry) (string, error) {
1038 path := MCPConfigPathForEntry(root, entry)
1039 switch entry.Source {
1040 case MCPSourceProjectMCPJSON:
1041 unlock, err := LockConfigFileEdits(path)
1042 if err != nil {
1043 return path, err
1044 }
1045 defer unlock()
1046 if _, err := UpsertMCPJSONPlugin(path, entry); err != nil {
1047 return path, err
1048 }
1049 return path, nil
1050 case MCPSourcePluginPackage:
1051 return "", fmt.Errorf("MCP server %q is managed by an installed plugin package", entry.Name)
1052 case MCPSourceProjectConfig:
1053 // Keep the project path selected above.
1054 default:
1055 path = UserConfigPath()
1056 if strings.TrimSpace(path) == "" {
1057 return "", fmt.Errorf("cannot resolve user config path")
1058 }
1059 entry.Source = MCPSourceUserConfig
1060 }
1061
1062 unlock, err := LockConfigFileEdits(path)
1063 if err != nil {
1064 return path, err
1065 }
1066 defer unlock()
1067 cfg, err := LoadForEditReadOnlyStrict(path)
1068 if err != nil {
1069 return path, err
1070 }
1071 if err := cfg.UpsertPlugin(entry); err != nil {
1072 return path, err
1073 }
1074 return path, cfg.SaveTo(path)
1075 }
1076
1077 // InstallUserPluginForRoot persists an explicit global MCP install together
1078 // with its durable activation state. All config sources that can shadow the
1079 // global declaration stay locked through conflict detection, save, activation,
1080 // and any rollback, so a failed activation write cannot remove or overwrite a
1081 // concurrent config update.
1082 func InstallUserPluginForRoot(root string, entry PluginEntry, forceEnable bool) (string, error) {
1083 entry.Source = MCPSourceUserConfig
1084 unlock, err := LockConfigFilesEdits(mcpConfigSourcePathsForRoot(root)...)
1085 if err != nil {
1086 return "", fmt.Errorf("install MCP server: %w", err)
1087 }
1088 defer unlock()
1089
1090 effective, err := LoadForRootReadOnly(root)
1091 if err != nil {
1092 return "", err
1093 }
1094 for _, configured := range effective.Plugins {
1095 if configured.Name != entry.Name {
1096 continue
1097 }
1098 if configured.Source != MCPSourceUserConfig && configured.Source != MCPSourceLegacyUser {
1099 return "", fmt.Errorf(
1100 "MCP server %q is already configured by %s; edit or remove that declaration before installing a global server with the same name",
1101 entry.Name,
1102 configured.Source,
1103 )
1104 }
1105 break
1106 }
1107
1108 path := UserConfigPath()
1109 if strings.TrimSpace(path) == "" {
1110 return "", fmt.Errorf("cannot resolve user config path")
1111 }
1112 cfg, err := LoadForEditReadOnlyStrict(path)
1113 if err != nil {
1114 return path, err
1115 }
1116 previous, hadPrevious := pluginEntryByName(cfg.Plugins, entry.Name)
1117 if err := cfg.UpsertPlugin(entry); err != nil {
1118 return path, err
1119 }
1120 if err := cfg.SaveTo(path); err != nil {
1121 return path, err
1122 }
1123
1124 store := DefaultMCPActivationStore()
1125 var activationErr error
1126 if forceEnable {
1127 activationErr = store.SetServerEnabled(entry, root, true)
1128 } else {
1129 activationErr = store.ClearServer(entry, root)
1130 }
1131 if activationErr == nil {
1132 return path, nil
1133 }
1134
1135 var restoreErr error
1136 if hadPrevious {
1137 restoreErr = cfg.UpsertPlugin(previous)
1138 } else {
1139 cfg.RemovePlugin(entry.Name)
1140 }
1141 if restoreErr == nil {
1142 restoreErr = cfg.SaveTo(path)
1143 }
1144 if restoreErr != nil {
1145 restoreErr = fmt.Errorf("restore MCP server config: %w", restoreErr)
1146 }
1147 return path, errors.Join(activationErr, restoreErr)
1148 }
1149
1150 // RemovePluginFromSourceForRoot removes exactly the declaration represented by
1151 // entry. Lower-priority same-name declarations are intentionally preserved so
1152 // they can become effective after a project override is removed.
1153 func RemovePluginFromSourceForRoot(root string, entry PluginEntry) (bool, string, error) {
1154 path := MCPConfigPathForEntry(root, entry)
1155 if entry.Source == MCPSourcePluginPackage {
1156 return false, "", fmt.Errorf("MCP server %q is managed by an installed plugin package", entry.Name)
1157 }
1158 if strings.TrimSpace(path) == "" {
1159 return false, "", nil
1160 }
1161 unlock, err := LockConfigFileEdits(path)
1162 if err != nil {
1163 return false, path, err
1164 }
1165 defer unlock()
1166 return removePluginFromSourceForRootLocked(entry, path)
1167 }
1168
1169 // removePluginFromSourceForRootLocked removes exactly one source declaration
1170 // while the caller holds that source's config edit lock.
1171 func removePluginFromSourceForRootLocked(entry PluginEntry, path string) (bool, string, error) {
1172 switch entry.Source {
1173 case MCPSourceProjectMCPJSON:
1174 removed, err := RemoveMCPJSONPlugin(path, entry.Name)
1175 return removed, path, err
1176 case MCPSourcePluginPackage:
1177 return false, "", fmt.Errorf("MCP server %q is managed by an installed plugin package", entry.Name)
1178 case MCPSourceLegacyUser:
1179 edit, changed, err := planLegacyMCPDisable(path, entry.Name)
1180 if err != nil || !changed {
1181 return false, path, err
1182 }
1183 if err := applyConfigSourceEdits([]configSourceEdit{edit}); err != nil {
1184 return false, path, err
1185 }
1186 return true, path, nil
1187 }
1188 cfg, err := LoadForEditReadOnlyStrict(path)
1189 if err != nil {
1190 return false, path, err
1191 }
1192 if !cfg.RemovePlugin(entry.Name) {
1193 return false, path, nil
1194 }
1195 if err := cfg.SaveTo(path); err != nil {
1196 return false, path, err
1197 }
1198 return true, path, nil
1199 }
1200
1201 // RemovePluginFromEffectiveSourceForRoot removes only the declaration currently
1202 // selected by the project-over-global precedence rules.
1203 func RemovePluginFromEffectiveSourceForRoot(root, name string) (PluginEntry, bool, string, error) {
1204 unlock, err := LockConfigFilesEdits(mcpConfigSourcePathsForRoot(root)...)
1205 if err != nil {
1206 return PluginEntry{}, false, "", fmt.Errorf("remove effective MCP server: %w", err)
1207 }
1208 defer unlock()
1209
1210 cfg, err := LoadForRootReadOnly(root)
1211 if err != nil {
1212 return PluginEntry{}, false, "", err
1213 }
1214 entry, found := pluginEntryByName(cfg.Plugins, strings.TrimSpace(name))
1215 if !found {
1216 return PluginEntry{}, false, "", nil
1217 }
1218 path := MCPConfigPathForEntry(root, entry)
1219 removed, path, err := removePluginFromSourceForRootLocked(entry, path)
1220 return entry, removed, path, err
1221 }
1222
1223 // mcpConfigSourcePathsForRoot returns every writable source whose precedence can
1224 // decide which declaration is effective. A source-selection operation must lock
1225 // all of them before loading, otherwise another process can add a higher-priority
1226 // declaration after the load and turn a "remove effective" action into a removal
1227 // of a now-shadowed source.
1228 func mcpConfigSourcePathsForRoot(root string) []string {
1229 resolvedRoot := resolveRoot(root)
1230 projectTOML := "reasonix.toml"
1231 projectMCPJSON := mcpJSONFile
1232 if resolvedRoot != "." {
1233 projectTOML = filepath.Join(resolvedRoot, "reasonix.toml")
1234 projectMCPJSON = filepath.Join(resolvedRoot, mcpJSONFile)
1235 }
1236 paths := append([]string{}, userConfigCandidatePaths()...)
1237 paths = append(paths, projectTOML, projectMCPJSON)
1238 if legacy := legacyConfigPath(); strings.TrimSpace(legacy) != "" {
1239 paths = append(paths, legacy)
1240 }
1241 return paths
1242 }
1243
1244 type configSourceEdit struct {
1245 path string
1246 resolvedPath string
1247 before []byte
1248 perm os.FileMode
1249 write func() error
1250 }
1251
1252 func newConfigSourceEdit(path string, write func() error) (configSourceEdit, error) {
1253 userOwned := isUserConfigPath(path) || samePath(path, legacyConfigPath())
1254 resolved, err := resolveConfigAccessPath(path, userOwned)
1255 if err != nil {
1256 return configSourceEdit{}, err
1257 }
1258 info, err := os.Stat(resolved)
1259 if err != nil {
1260 return configSourceEdit{}, err
1261 }
1262 before, err := os.ReadFile(resolved)
1263 if err != nil {
1264 return configSourceEdit{}, err
1265 }
1266 return configSourceEdit{
1267 path: path,
1268 resolvedPath: resolved,
1269 before: before,
1270 perm: info.Mode().Perm(),
1271 write: write,
1272 }, nil
1273 }
1274
1275 func applyConfigSourceEdits(edits []configSourceEdit) error {
1276 for i := range edits {
1277 if err := edits[i].write(); err != nil {
1278 var rollbackErrs []error
1279 for j := i; j >= 0; j-- {
1280 if rollbackErr := fileutil.AtomicWriteFile(edits[j].resolvedPath, edits[j].before, edits[j].perm); rollbackErr != nil {
1281 rollbackErrs = append(rollbackErrs, fmt.Errorf("restore %s: %w", edits[j].path, rollbackErr))
1282 }
1283 }
1284 if rollbackErr := errors.Join(rollbackErrs...); rollbackErr != nil {
1285 return errors.Join(err, fmt.Errorf("roll back MCP config removal: %w", rollbackErr))
1286 }
1287 return err
1288 }
1289 }
1290 return nil
1291 }
1292
1293 func planTOMLPluginRemoval(path, name string) (configSourceEdit, bool, error) {
1294 _, exists, err := statConfigPath(path)
1295 if err != nil {
1296 return configSourceEdit{}, false, err
1297 }
1298 if !exists {
1299 return configSourceEdit{}, false, nil
1300 }
1301 cfg := Default()
1302 if err := mergeFile(cfg, path); err != nil {
1303 return configSourceEdit{}, false, err
1304 }
1305 normalizeConfigForEdit(cfg)
1306 if !cfg.RemovePlugin(name) {
1307 return configSourceEdit{}, false, nil
1308 }
1309 edit, err := newConfigSourceEdit(path, func() error { return cfg.SaveTo(path) })
1310 return edit, err == nil, err
1311 }
1312
1313 func planMCPJSONPluginRemoval(path, name string) (configSourceEdit, bool, error) {
1314 resolved, exists, err := statConfigPath(path)
1315 if err != nil {
1316 return configSourceEdit{}, false, err
1317 }
1318 if !exists {
1319 return configSourceEdit{}, false, nil
1320 }
1321 root, servers, err := readMCPJSONRaw(resolved)
1322 if err != nil {
1323 return configSourceEdit{}, false, err
1324 }
1325 if _, ok := servers[name]; !ok {
1326 return configSourceEdit{}, false, nil
1327 }
1328 delete(servers, name)
1329 edit, err := newConfigSourceEdit(path, func() error { return writeMCPJSONServers(resolved, root, servers) })
1330 return edit, err == nil, err
1331 }
1332
1333 func planLegacyMCPDisable(path, name string) (configSourceEdit, bool, error) {
1334 if strings.TrimSpace(path) == "" {
1335 return configSourceEdit{}, false, nil
1336 }
1337 resolved, err := resolveConfigAccessPath(path, true)
1338 if err != nil {
1339 return configSourceEdit{}, false, err
1340 }
1341 info, err := os.Stat(resolved)
1342 if err != nil {
1343 if os.IsNotExist(err) {
1344 return configSourceEdit{}, false, nil
1345 }
1346 return configSourceEdit{}, false, err
1347 }
1348 data, err := fileencoding.ReadFileUTF8(resolved)
1349 if err != nil {
1350 return configSourceEdit{}, false, err
1351 }
1352 var root map[string]json.RawMessage
1353 var view struct {
1354 MCP []string `json:"mcp"`
1355 MCPServers map[string]json.RawMessage `json:"mcpServers"`
1356 MCPDisabled []string `json:"mcpDisabled"`
1357 }
1358 if err := json.Unmarshal(data, &root); err != nil {
1359 return configSourceEdit{}, false, nil
1360 }
1361 if err := json.Unmarshal(data, &view); err != nil {
1362 return configSourceEdit{}, false, nil
1363 }
1364
1365 foundNamed := false
1366 changed := false
1367 filtered := make([]string, 0, len(view.MCP))
1368 for i, raw := range view.MCP {
1369 entry, ok := parseLegacyMCPSpec(raw)
1370 if !ok {
1371 filtered = append(filtered, raw)
1372 continue
1373 }
1374 effectiveName := entry.Name
1375 if effectiveName == "" {
1376 effectiveName = anonymousMCPName(i)
1377 }
1378 if effectiveName != name {
1379 filtered = append(filtered, raw)
1380 continue
1381 }
1382 if entry.Name == "" {
1383 changed = true
1384 continue
1385 }
1386 foundNamed = true
1387 filtered = append(filtered, raw)
1388 }
1389 if _, ok := view.MCPServers[name]; ok {
1390 foundNamed = true
1391 }
1392 if foundNamed && !containsString(view.MCPDisabled, name) {
1393 view.MCPDisabled = append(view.MCPDisabled, name)
1394 changed = true
1395 }
1396 if !changed {
1397 return configSourceEdit{}, false, nil
1398 }
1399 if len(filtered) != len(view.MCP) {
1400 raw, marshalErr := json.Marshal(filtered)
1401 if marshalErr != nil {
1402 return configSourceEdit{}, false, marshalErr
1403 }
1404 root["mcp"] = raw
1405 }
1406 disabledRaw, err := json.Marshal(view.MCPDisabled)
1407 if err != nil {
1408 return configSourceEdit{}, false, err
1409 }
1410 root["mcpDisabled"] = disabledRaw
1411 out, err := json.MarshalIndent(root, "", " ")
1412 if err != nil {
1413 return configSourceEdit{}, false, err
1414 }
1415 out = append(out, '\n')
1416 edit, err := newConfigSourceEdit(path, func() error {
1417 return fileutil.AtomicWriteFile(resolved, out, info.Mode().Perm())
1418 })
1419 return edit, err == nil, err
1420 }
1421
1422 // RemovePluginFromSourcesForRoot removes an MCP server from every writable
1423 // config source that can contribute it for root. Removing all matching TOML
1424 // declarations prevents a lower-priority duplicate from reappearing after the
1425 // higher-priority entry is deleted. Every edit is planned before the first write,
1426 // and legacy JSON receives a disable marker for older Reasonix versions.
1427 func RemovePluginFromSourcesForRoot(root, name string) (bool, error) {
1428 name = strings.TrimSpace(name)
1429 if name == "" {
1430 return false, fmt.Errorf("remove MCP server: name is required")
1431 }
1432
1433 userPaths := userConfigCandidatePaths()
1434 resolvedRoot := resolveRoot(root)
1435 projectTOML := "reasonix.toml"
1436 if resolvedRoot != "." {
1437 projectTOML = filepath.Join(resolvedRoot, "reasonix.toml")
1438 }
1439 isUserPath := false
1440 for _, path := range userPaths {
1441 if samePath(path, projectTOML) {
1442 isUserPath = true
1443 break
1444 }
1445 }
1446 mcpPath := mcpJSONFile
1447 if resolvedRoot != "." {
1448 mcpPath = filepath.Join(resolvedRoot, mcpJSONFile)
1449 }
1450 legacyPath := legacyConfigPath()
1451 lockPaths := append([]string{}, userPaths...)
1452 if !isUserPath {
1453 lockPaths = append(lockPaths, projectTOML)
1454 }
1455 lockPaths = append(lockPaths, mcpPath)
1456 if legacyPath != "" {
1457 lockPaths = append(lockPaths, legacyPath)
1458 }
1459 unlock, err := lockConfigFilesEdits(lockPaths...)
1460 if err != nil {
1461 return false, fmt.Errorf("remove MCP server: %w", err)
1462 }
1463 defer unlock()
1464
1465 var edits []configSourceEdit
1466 planTOML := func(path string) error {
1467 edit, changed, err := planTOMLPluginRemoval(path, name)
1468 if err != nil {
1469 return err
1470 }
1471 if changed {
1472 edits = append(edits, edit)
1473 }
1474 return nil
1475 }
1476 for _, path := range userPaths {
1477 if err := planTOML(path); err != nil {
1478 return false, err
1479 }
1480 }
1481 if !isUserPath {
1482 if err := planTOML(projectTOML); err != nil {
1483 return false, err
1484 }
1485 }
1486
1487 mcpEdit, changed, err := planMCPJSONPluginRemoval(mcpPath, name)
1488 if err != nil {
1489 return false, err
1490 }
1491 if changed {
1492 edits = append(edits, mcpEdit)
1493 }
1494 legacyEdit, changed, err := planLegacyMCPDisable(legacyPath, name)
1495 if err != nil {
1496 return false, err
1497 }
1498 if changed {
1499 edits = append(edits, legacyEdit)
1500 }
1501 if len(edits) == 0 {
1502 return false, nil
1503 }
1504 if err := applyConfigSourceEdits(edits); err != nil {
1505 return false, err
1506 }
1507 return true, nil
1508 }
1509
1510 // validatePlugin checks a plugin entry by transport. An empty Type means stdio.
1511 func validatePlugin(e PluginEntry) error {
1512 if strings.TrimSpace(e.Name) == "" {
1513 return fmt.Errorf("plugin: name is required")
1514 }
1515 if e.StartupTimeoutSeconds < 0 {
1516 return fmt.Errorf("plugin %q: startup_timeout_seconds must be >= 0", e.Name)
1517 }
1518 if e.CallTimeoutSeconds < 0 {
1519 return fmt.Errorf("plugin %q: call_timeout_seconds must be >= 0", e.Name)
1520 }
1521 for name, sec := range e.ToolTimeoutSeconds {
1522 if strings.TrimSpace(name) == "" {
1523 return fmt.Errorf("plugin %q: tool_timeout_seconds contains an empty tool name", e.Name)
1524 }
1525 if sec < 0 {
1526 return fmt.Errorf("plugin %q: tool_timeout_seconds[%q] must be >= 0", e.Name, name)
1527 }
1528 }
1529 switch strings.ToLower(strings.TrimSpace(e.Type)) {
1530 case "", "stdio":
1531 if strings.TrimSpace(e.Command) == "" {
1532 return fmt.Errorf("plugin %q: command is required for a stdio server", e.Name)
1533 }
1534 case "http", "sse", "streamable-http":
1535 if strings.TrimSpace(e.URL) == "" {
1536 return fmt.Errorf("plugin %q: url is required for a %s server", e.Name, e.Type)
1537 }
1538 default:
1539 return fmt.Errorf("plugin %q: unknown type %q (want stdio|http|sse)", e.Name, e.Type)
1540 }
1541 return nil
1542 }
1543
1544 // SaveTo writes the configuration to path as annotated TOML, atomically: it
1545 // writes a sibling temp file then renames, so a crash mid-write can't leave a
1546 // half-written reasonix.toml that fails to parse on next load. Parent directories
1547 // are created as needed.
1548 //
1549 // For project configs (./reasonix.toml) the write is incremental: only sections
1550 // and fields that differ from built-in defaults are written, so the file never
1551 // accumulates fields that override the user's global config. User configs still
1552 // write the full annotated template since they are the user's own settings store.
1553 func (c *Config) SaveTo(path string) error {
1554 if c == nil {
1555 return fmt.Errorf("save config: nil config")
1556 }
1557 if c.editLoadErr != nil {
1558 return fmt.Errorf("save config loaded from %q: %w", path, c.editLoadErr)
1559 }
1560 scope := renderScopeForPath(path)
1561 if scope == RenderScopeUser {
1562 if err := currentUserConfigEditLockError(); err != nil {
1563 return fmt.Errorf("save user config: %w", err)
1564 }
1565 }
1566 resolved, err := resolveConfigAccessPath(path, scope == RenderScopeUser)
1567 if err != nil {
1568 return err
1569 }
1570 if scope == RenderScopeProject {
1571 return c.saveProjectIncrementalResolved(path, resolved)
1572 }
1573 return c.writeModelConfigResolved(resolved, RenderTOMLForScope(c, scope), configFilePerm(path))
1574 }
1575
1576 func (c *Config) SaveToScope(path string, scope RenderScope) error {
1577 if c == nil {
1578 return fmt.Errorf("save config: nil config")
1579 }
1580 if c.editLoadErr != nil {
1581 return fmt.Errorf("save config loaded from %q: %w", path, c.editLoadErr)
1582 }
1583 if strings.TrimSpace(path) == "" {
1584 return fmt.Errorf("save: empty config path")
1585 }
1586 userConfig := scope == RenderScopeUser || (scope == RenderScopeFull && isUserConfigPath(path))
1587 if userConfig {
1588 if err := currentUserConfigEditLockError(); err != nil {
1589 return fmt.Errorf("save user config: %w", err)
1590 }
1591 }
1592 resolved, err := resolveConfigAccessPath(path, userConfig)
1593 if err != nil {
1594 return err
1595 }
1596 return c.writeModelConfigResolved(resolved, RenderTOMLForScope(c, scope), configFilePerm(path))
1597 }
1598
1599 func (c *Config) saveProjectIncrementalResolved(logicalPath, resolvedPath string) error {
1600 raw, err := fileencoding.ReadFileUTF8(resolvedPath)
1601 if err != nil {
1602 if !os.IsNotExist(err) {
1603 return err
1604 }
1605 raw = nil
1606 }
1607
1608 body := string(raw)
1609 isNew := body == ""
1610 if isNew {
1611 return c.writeModelConfigResolved(resolvedPath, RenderTOMLForScope(c, RenderScopeProject), configFilePerm(logicalPath))
1612 }
1613 delta := RenderTOMLProjectDelta(c)
1614 if tomlBodyHasTopLevelKey(body, "config_version") && !tomlBodyHasTopLevelKey(delta, "config_version") {
1615 delta = fmt.Sprintf("config_version = %d\n", configVersion(c)) + delta
1616 }
1617 removePlugins := len(tomlPluginsForScope(c.Plugins, RenderScopeProject)) == 0 && tomlBodyHasSection(body, "plugins")
1618 removeSandboxBash := shouldRemoveIneffectiveProjectSandboxBash(body, c)
1619 removeSkills := projectSkillsKeysToRemove(body, c)
1620 _, hasLegacyDesktopAutoGuard := tomlSectionKeyValue(body, "desktop", "default_auto_recovery_checkpoint")
1621 _, hasRetiredAgentAutoGuard := tomlSectionKeyValue(body, "agent", "auto_recovery_checkpoint")
1622 _, hasRetiredRecoveryModel := tomlSectionKeyValue(body, "agent", "recovery_model")
1623 _, hasRetiredRecoveryTemperature := tomlSectionKeyValue(body, "agent", "recovery_temperature")
1624 removeRetiredAutoGuard := hasLegacyDesktopAutoGuard || hasRetiredAgentAutoGuard || hasRetiredRecoveryModel || hasRetiredRecoveryTemperature
1625 writeProviderAccess := c.Desktop.ProviderAccess != nil
1626 if strings.TrimSpace(delta) == "" && !removePlugins && !removeSandboxBash && !removeSkills && !removeRetiredAutoGuard && !writeProviderAccess {
1627 return nil // no changes to write
1628 }
1629 // Parse delta into section blocks and merge each into body
1630 if strings.TrimSpace(delta) != "" {
1631 body = mergeTOMLDelta(body, delta)
1632 }
1633 if removePlugins {
1634 body = removeTOMLSection(body, "plugins")
1635 }
1636 if removeSandboxBash {
1637 body = removeTOMLSectionKey(body, "sandbox", "bash")
1638 }
1639 if removeSkills {
1640 body = cleanupProjectSkillsKeys(body, c)
1641 }
1642 if removeRetiredAutoGuard {
1643 body = removeTOMLSectionKey(body, "desktop", "default_auto_recovery_checkpoint")
1644 body = removeTOMLSectionKey(body, "agent", "auto_recovery_checkpoint")
1645 body = removeTOMLSectionKey(removeTOMLSectionKey(body, "agent", "recovery_model"), "agent", "recovery_temperature")
1646 }
1647 if writeProviderAccess {
1648 body = upsertTOMLSectionKey(body, "desktop", "provider_access", "provider_access = "+renderStringArray(c.Desktop.ProviderAccess))
1649 }
1650 return c.writeModelConfigResolved(resolvedPath, body, configFilePerm(logicalPath))
1651 }
1652
1653 // projectSkillsKeysToRemove reports whether an existing project [skills]
1654 // section contains a field whose current edit value is the built-in default.
1655 // Project saves are incremental, so an empty RenderTOMLProjectDelta cannot
1656 // remove a stale override without this explicit cleanup pass.
1657 func projectSkillsKeysToRemove(body string, c *Config) bool {
1658 if c == nil || !tomlBodyHasSection(body, "skills") {
1659 return false
1660 }
1661 for _, key := range projectSkillKeys {
1662 if projectSkillKeyIsDefault(c, key) {
1663 if _, ok := tomlSectionKeyValue(body, "skills", key); ok {
1664 return true
1665 }
1666 }
1667 }
1668 return false
1669 }
1670
1671 var projectSkillKeys = [...]string{"paths", "excluded_paths", "disabled_skills", "disable_implicit_invocation", "max_depth"}
1672
1673 func projectSkillKeyIsDefault(c *Config, key string) bool {
1674 if c != nil && c.keepsProjectSkillKey(key) {
1675 return false
1676 }
1677 switch key {
1678 case "paths":
1679 return len(c.Skills.Paths) == 0
1680 case "excluded_paths":
1681 return len(c.Skills.ExcludedPaths) == 0
1682 case "disabled_skills":
1683 return len(c.Skills.DisabledSkills) == 0
1684 case "disable_implicit_invocation":
1685 return !c.Skills.DisableImplicitInvocation
1686 case "max_depth":
1687 return c.Skills.MaxDepth == 0
1688 default:
1689 return false
1690 }
1691 }
1692
1693 func cleanupProjectSkillsKeys(body string, c *Config) string {
1694 if c == nil {
1695 return body
1696 }
1697 for _, key := range projectSkillKeys {
1698 if projectSkillKeyIsDefault(c, key) {
1699 body = removeTOMLSectionKey(body, "skills", key)
1700 }
1701 }
1702 return body
1703 }
1704
1705 func shouldRemoveIneffectiveProjectSandboxBash(body string, c *Config) bool {
1706 if c == nil || runtimeGOOS != "windows" {
1707 return false
1708 }
1709 if c.BashMode() != "off" {
1710 return false
1711 }
1712 value, ok := tomlSectionKeyValue(body, "sandbox", "bash")
1713 return ok && tomlStringLiteralEquals(value, "enforce")
1714 }
1715
1716 // mergeTOMLDelta parses delta into named TOML blocks and merges each into body
1717 // via replaceTOMLSection. Consecutive array-of-tables entries ([[plugins]],
1718 // [[providers]]) with the same name are merged into a single block so the
1719 // replacement doesn't lose entries.
1720 func mergeTOMLDelta(body, delta string) string {
1721 lines := strings.Split(delta, "\n")
1722 type section struct {
1723 name string
1724 content string
1725 isArray bool
1726 }
1727 var topLevel strings.Builder
1728 var sections []section
1729 var curName string
1730 var curBuf strings.Builder
1731 curIsArray := false
1732
1733 flush := func() {
1734 if curName == "" {
1735 return
1736 }
1737 content := curBuf.String()
1738 if curIsArray && len(sections) > 0 && sections[len(sections)-1].isArray && sections[len(sections)-1].name == curName {
1739 sections[len(sections)-1].content += content
1740 } else {
1741 sections = append(sections, section{curName, content, curIsArray})
1742 }
1743 curBuf.Reset()
1744 }
1745
1746 for _, line := range lines {
1747 if name, isArray, ok := tomlEditSectionHeader(line); ok {
1748 flush()
1749 curName = name
1750 curIsArray = isArray
1751 curBuf.WriteString(line + "\n")
1752 continue
1753 }
1754 if curName != "" {
1755 curBuf.WriteString(line + "\n")
1756 continue
1757 }
1758 if strings.TrimSpace(line) != "" {
1759 topLevel.WriteString(line + "\n")
1760 }
1761 }
1762 flush()
1763
1764 if top := strings.TrimSpace(topLevel.String()); top != "" {
1765 body = mergeTOMLTopLevelFields(body, top+"\n")
1766 }
1767 for _, s := range sections {
1768 body = replaceTOMLSection(body, s.name, s.content)
1769 }
1770 return body
1771 }
1772
1773 func mergeTOMLTopLevelFields(body, fields string) string {
1774 for line := range strings.SplitSeq(fields, "\n") {
1775 line = strings.TrimSpace(line)
1776 if line == "" {
1777 continue
1778 }
1779 key, ok := tomlTopLevelKey(line)
1780 if !ok {
1781 continue
1782 }
1783 body = replaceTOMLTopLevelField(body, key, line+"\n")
1784 }
1785 return body
1786 }
1787
1788 // SaveMinimalProjectReasoningLanguage writes a new project config that only
1789 // overrides [agent].reasoning_language.
1790 func SaveMinimalProjectReasoningLanguage(path, lang string) (string, error) {
1791 cfg := Default()
1792 if err := cfg.SetReasoningLanguage(lang); err != nil {
1793 return "", err
1794 }
1795 body := fmt.Sprintf(`# Reasonix project configuration.
1796 # Project-local overrides are merged over the user config.
1797
1798 [agent]
1799 reasoning_language = %q
1800 `, cfg.ReasoningLanguage())
1801 return cfg.ReasoningLanguage(), writeConfigFile(path, body)
1802 }
1803
1804 // SaveMinimalProjectCompactRatio writes a new project config that only
1805 // overrides [agent].compact_ratio.
1806 func SaveMinimalProjectCompactRatio(path string, ratio float64) (float64, error) {
1807 cfg := Default()
1808 if err := cfg.SetCompactRatio(ratio); err != nil {
1809 return 0, err
1810 }
1811 body := fmt.Sprintf(`# Reasonix project configuration.
1812 # Project-local overrides are merged over the user config.
1813
1814 [agent]
1815 compact_ratio = %s
1816 `, formatFloat(cfg.Agent.CompactRatio))
1817 return cfg.Agent.CompactRatio, writeConfigFile(path, body)
1818 }
1819
1820 func writeConfigFile(path, body string) error {
1821 if strings.TrimSpace(path) == "" {
1822 return fmt.Errorf("save: empty config path")
1823 }
1824 return atomicWriteToConfigFile(path, body, configFilePerm(path))
1825 }
1826
1827 func writeConfigFileResolved(path, body string, perm os.FileMode) error {
1828 if strings.TrimSpace(path) == "" {
1829 return fmt.Errorf("save: empty config path")
1830 }
1831 if err := finalizeOpenCodeGoJournal(path); err != nil {
1832 return err
1833 }
1834 return fileutil.AtomicWriteFileStrict(path, []byte(body), perm)
1835 }
1836
1837 // atomicWriteToConfigFile resolves the path once and writes only the validated
1838 // final target. This preserves valid links and fails closed for broken user
1839 // links or project links that escape their project root.
1840 func atomicWriteToConfigFile(path, body string, perm os.FileMode) error {
1841 resolved, err := resolveConfigReadPath(path)
1842 if err != nil {
1843 return err
1844 }
1845 if err := finalizeOpenCodeGoJournal(resolved); err != nil {
1846 return err
1847 }
1848 if err := fileutil.AtomicWriteFile(resolved, []byte(body), perm); err != nil {
1849 return fmt.Errorf("write symlink target %q: %w", resolved, err)
1850 }
1851 return nil
1852 }
1853
1854 func configFilePerm(path string) os.FileMode {
1855 if isUserConfigPath(path) {
1856 return 0o600
1857 }
1858 return 0o644
1859 }
1860
1861 // WritePermissionsAllow updates only permissions.allow in a TOML file. All
1862 // other permission policy fields and unrelated content remain byte-for-byte
1863 // unchanged. Callers must validate and lock the latest file across their full
1864 // read-modify-write transaction before calling this function.
1865 func WritePermissionsAllow(path string, allow []string) error {
1866 if strings.TrimSpace(path) == "" {
1867 return fmt.Errorf("write permissions: empty config path")
1868 }
1869
1870 resolved, exists, err := statConfigPath(path)
1871 if err != nil {
1872 return err
1873 }
1874 var raw []byte
1875 if exists {
1876 raw, err = fileencoding.ReadFileUTF8(resolved)
1877 if err != nil {
1878 return err
1879 }
1880 } else {
1881 raw = nil
1882 }
1883
1884 body := string(raw)
1885 if body == "" {
1886 body = fmt.Sprintf("[permissions]\nallow = %s\n", renderStringArray(allow))
1887 } else {
1888 body = upsertTOMLSectionKey(body, "permissions", "allow", "allow = "+renderStringArray(allow))
1889 }
1890
1891 var candidate Config
1892 if _, err := toml.Decode(body, &candidate); err != nil {
1893 return fmt.Errorf("write permissions: validate updated config: %w", err)
1894 }
1895 if !slices.Equal(candidate.Permissions.Allow, allow) {
1896 return fmt.Errorf("write permissions: validate updated allow: got %v, want %v", candidate.Permissions.Allow, allow)
1897 }
1898 return writeConfigFileResolved(resolved, body, configFilePerm(path))
1899 }
1900
1901 // replaceTOMLSection replaces the content of a named TOML section (including
1902 // its header line) with newContent. It handles both [section] and [[section]]
1903 // array-of-tables headers. If the section doesn't exist, newContent is appended
1904 // at the end.
1905 func replaceTOMLSection(body, sectionName, newContent string) string {
1906 spans := tomlLineSpans(body)
1907 structural := tomlStructuralLineMask(spans)
1908 arrayIdx := -1
1909 for i, span := range spans {
1910 if !structural[i] {
1911 continue
1912 }
1913 name, isArray, ok := tomlEditSectionHeader(span.text)
1914 if ok && isArray && name == sectionName {
1915 arrayIdx = i
1916 break
1917 }
1918 }
1919 if arrayIdx >= 0 {
1920 start := spans[arrayIdx].start
1921 end := len(body)
1922 for i := arrayIdx + 1; i < len(spans); i++ {
1923 if !structural[i] {
1924 continue
1925 }
1926 name, isArray, ok := tomlEditSectionHeader(spans[i].text)
1927 if !ok {
1928 continue
1929 }
1930 if (isArray && name == sectionName) || strings.HasPrefix(name, sectionName+".") {
1931 continue
1932 }
1933 end = spans[i].start
1934 break
1935 }
1936 return body[:start] + strings.TrimRight(newContent, "\n") + "\n" + body[end:]
1937 }
1938
1939 for i, span := range spans {
1940 if !structural[i] {
1941 continue
1942 }
1943 name, isArray, ok := tomlEditSectionHeader(span.text)
1944 if !ok || isArray || name != sectionName {
1945 continue
1946 }
1947 end := len(body)
1948 for nextIdx, next := range spans {
1949 if !structural[nextIdx] {
1950 continue
1951 }
1952 if next.start <= span.start {
1953 continue
1954 }
1955 if _, _, ok := tomlEditSectionHeader(next.text); ok {
1956 end = next.start
1957 break
1958 }
1959 }
1960 return body[:span.start] + newContent + body[end:]
1961 }
1962 return strings.TrimRight(body, "\n") + "\n\n" + newContent
1963 }
1964
1965 func upsertTOMLSectionKey(body, sectionName, key, line string) string {
1966 line = strings.TrimRight(line, "\r\n") + "\n"
1967 spans := tomlLineSpans(body)
1968 structural := tomlStructuralLineMask(spans)
1969 sectionIdx := -1
1970 sectionEnd := len(body)
1971 for i, span := range spans {
1972 if !structural[i] {
1973 continue
1974 }
1975 name, isArray, ok := tomlEditSectionHeader(span.text)
1976 if ok {
1977 if sectionIdx >= 0 {
1978 sectionEnd = span.start
1979 break
1980 }
1981 if !isArray && name == sectionName {
1982 sectionIdx = i
1983 }
1984 continue
1985 }
1986 if sectionIdx >= 0 {
1987 if got, _, ok := tomlKeyValue(span.text); ok && got == key {
1988 endIdx := tomlValueEndSpan(spans, i)
1989 end := spans[endIdx].end
1990 if endIdx > i {
1991 if comments := tomlCommentsInSpans(spans, i, endIdx); len(comments) > 0 {
1992 line = strings.Join(comments, "\n") + "\n" + line
1993 }
1994 } else if comment := tomlInlineComment(spans[endIdx].text); comment != "" {
1995 line = strings.TrimRight(line, "\r\n") + " " + comment + "\n"
1996 }
1997 return body[:span.start] + line + body[end:]
1998 }
1999 }
2000 }
2001 if sectionIdx < 0 {
2002 block := fmt.Sprintf("[%s]\n%s", sectionName, line)
2003 return replaceTOMLSection(body, sectionName, block)
2004 }
2005 prefix := body[:sectionEnd]
2006 if prefix != "" && !strings.HasSuffix(prefix, "\n") {
2007 prefix += "\n"
2008 }
2009 return prefix + line + body[sectionEnd:]
2010 }
2011
2012 type tomlLexState struct {
2013 stringKind tomlStringKind
2014 escaped bool
2015 }
2016
2017 type tomlStringKind uint8
2018
2019 const (
2020 tomlStringNone tomlStringKind = iota
2021 tomlStringBasic
2022 tomlStringLiteral
2023 tomlStringMultilineBasic
2024 tomlStringMultilineLiteral
2025 )
2026
2027 func (s tomlLexState) inMultilineString() bool {
2028 return s.stringKind == tomlStringMultilineBasic || s.stringKind == tomlStringMultilineLiteral
2029 }
2030
2031 func scanTOMLLine(line string, state *tomlLexState, outsideString func(byte)) int {
2032 for i := 0; i < len(line); {
2033 ch := line[i]
2034 switch state.stringKind {
2035 case tomlStringBasic:
2036 if state.escaped {
2037 state.escaped = false
2038 i++
2039 continue
2040 }
2041 switch ch {
2042 case '\\':
2043 state.escaped = true
2044 case '"':
2045 state.stringKind = tomlStringNone
2046 }
2047 i++
2048 continue
2049 case tomlStringLiteral:
2050 if ch == '\'' {
2051 state.stringKind = tomlStringNone
2052 }
2053 i++
2054 continue
2055 case tomlStringMultilineBasic:
2056 if state.escaped {
2057 state.escaped = false
2058 i++
2059 continue
2060 }
2061 if ch == '\\' {
2062 state.escaped = true
2063 i++
2064 continue
2065 }
2066 if ch == '"' {
2067 run := tomlQuoteRun(line, i, '"')
2068 if run >= 3 {
2069 state.stringKind = tomlStringNone
2070 }
2071 i += run
2072 continue
2073 }
2074 i++
2075 continue
2076 case tomlStringMultilineLiteral:
2077 if ch == '\'' {
2078 run := tomlQuoteRun(line, i, '\'')
2079 if run >= 3 {
2080 state.stringKind = tomlStringNone
2081 }
2082 i += run
2083 continue
2084 }
2085 i++
2086 continue
2087 }
2088
2089 switch ch {
2090 case '#':
2091 return i
2092 case '"':
2093 run := tomlQuoteRun(line, i, '"')
2094 switch {
2095 case run == 1:
2096 state.stringKind = tomlStringBasic
2097 case run >= 3 && run < 6:
2098 state.stringKind = tomlStringMultilineBasic
2099 }
2100 i += run
2101 continue
2102 case '\'':
2103 run := tomlQuoteRun(line, i, '\'')
2104 switch {
2105 case run == 1:
2106 state.stringKind = tomlStringLiteral
2107 case run >= 3 && run < 6:
2108 state.stringKind = tomlStringMultilineLiteral
2109 }
2110 i += run
2111 continue
2112 default:
2113 if outsideString != nil {
2114 outsideString(ch)
2115 }
2116 }
2117 i++
2118 }
2119 return -1
2120 }
2121
2122 func tomlQuoteRun(line string, start int, quote byte) int {
2123 end := start
2124 for end < len(line) && line[end] == quote {
2125 end++
2126 }
2127 return end - start
2128 }
2129
2130 func tomlStructuralLineMask(spans []tomlLineSpan) []bool {
2131 structural := make([]bool, len(spans))
2132 state := tomlLexState{}
2133 for i, span := range spans {
2134 structural[i] = !state.inMultilineString()
2135 scanTOMLLine(span.text, &state, nil)
2136 }
2137 return structural
2138 }
2139
2140 func tomlValueEndSpan(spans []tomlLineSpan, start int) int {
2141 if start < 0 || start >= len(spans) {
2142 return start
2143 }
2144 _, value, ok := tomlKeyValue(spans[start].text)
2145 if !ok || !strings.HasPrefix(strings.TrimSpace(value), "[") {
2146 return start
2147 }
2148 depth := 0
2149 seenArray := false
2150 state := tomlLexState{}
2151 for i := start; i < len(spans); i++ {
2152 closed := false
2153 scanTOMLLine(spans[i].text, &state, func(ch byte) {
2154 switch ch {
2155 case '[':
2156 seenArray = true
2157 depth++
2158 case ']':
2159 if seenArray {
2160 depth--
2161 closed = depth == 0
2162 }
2163 }
2164 })
2165 if closed {
2166 return i
2167 }
2168 }
2169 return start
2170 }
2171
2172 func tomlInlineComment(line string) string {
2173 state := tomlLexState{}
2174 if i := scanTOMLLine(line, &state, nil); i >= 0 {
2175 return strings.TrimRight(line[i:], "\r\n")
2176 }
2177 return ""
2178 }
2179
2180 func tomlCommentsInSpans(spans []tomlLineSpan, start, end int) []string {
2181 state := tomlLexState{}
2182 var comments []string
2183 for i := start; i <= end; i++ {
2184 line := spans[i].text
2185 commentAt := scanTOMLLine(line, &state, nil)
2186 if commentAt < 0 {
2187 continue
2188 }
2189 indentEnd := 0
2190 for indentEnd < len(line) && (line[indentEnd] == ' ' || line[indentEnd] == '\t') {
2191 indentEnd++
2192 }
2193 comment := strings.TrimRight(line[commentAt:], "\r\n")
2194 comments = append(comments, line[:indentEnd]+comment)
2195 }
2196 return comments
2197 }
2198
2199 func removeTOMLSection(body, sectionName string) string {
2200 spans := tomlLineSpans(body)
2201 for i, span := range spans {
2202 name, isArray, ok := tomlEditSectionHeader(span.text)
2203 if !ok || name != sectionName {
2204 continue
2205 }
2206 end := len(body)
2207 for j := i + 1; j < len(spans); j++ {
2208 nextName, nextIsArray, ok := tomlEditSectionHeader(spans[j].text)
2209 if !ok {
2210 continue
2211 }
2212 if (isArray && nextIsArray && nextName == sectionName) || strings.HasPrefix(nextName, sectionName+".") {
2213 continue
2214 }
2215 end = spans[j].start
2216 break
2217 }
2218 return strings.TrimRight(body[:span.start], "\n") + "\n" + body[end:]
2219 }
2220 return body
2221 }
2222
2223 func removeTOMLSectionKey(body, sectionName, key string) string {
2224 spans := tomlLineSpans(body)
2225 sectionIdx := -1
2226 keyIdx := -1
2227 endIdx := len(spans)
2228 for i, span := range spans {
2229 name, isArray, ok := tomlEditSectionHeader(span.text)
2230 if ok {
2231 if sectionIdx >= 0 {
2232 endIdx = i
2233 break
2234 }
2235 if !isArray && name == sectionName {
2236 sectionIdx = i
2237 }
2238 continue
2239 }
2240 if sectionIdx >= 0 && keyIdx < 0 {
2241 if got, _, ok := tomlKeyValue(span.text); ok && got == key {
2242 keyIdx = i
2243 }
2244 }
2245 }
2246 if sectionIdx < 0 || keyIdx < 0 {
2247 return body
2248 }
2249 keyEndIdx := tomlValueEndSpan(spans, keyIdx)
2250 for i := sectionIdx + 1; i < endIdx; i++ {
2251 if i >= keyIdx && i <= keyEndIdx {
2252 continue
2253 }
2254 trimmed := strings.TrimSpace(spans[i].text)
2255 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
2256 continue
2257 }
2258 return body[:spans[keyIdx].start] + body[spans[keyEndIdx].end:]
2259 }
2260 sectionStart := spans[sectionIdx].start
2261 sectionEnd := len(body)
2262 if endIdx < len(spans) {
2263 sectionEnd = spans[endIdx].start
2264 }
2265 return strings.TrimRight(body[:sectionStart], "\n") + "\n" + body[sectionEnd:]
2266 }
2267
2268 type tomlLineSpan struct {
2269 start int
2270 end int
2271 text string
2272 }
2273
2274 func tomlLineSpans(body string) []tomlLineSpan {
2275 if body == "" {
2276 return nil
2277 }
2278 var spans []tomlLineSpan
2279 for start := 0; start < len(body); {
2280 end := len(body)
2281 if idx := strings.IndexByte(body[start:], '\n'); idx >= 0 {
2282 end = start + idx + 1
2283 }
2284 spans = append(spans, tomlLineSpan{start: start, end: end, text: body[start:end]})
2285 start = end
2286 }
2287 return spans
2288 }
2289
2290 func tomlEditSectionHeader(line string) (string, bool, bool) {
2291 trimmed := strings.TrimSpace(line)
2292 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
2293 return "", false, false
2294 }
2295 if before, _, ok := strings.Cut(trimmed, "#"); ok {
2296 trimmed = strings.TrimSpace(before)
2297 }
2298 if strings.HasPrefix(trimmed, "[[") && strings.HasSuffix(trimmed, "]]") {
2299 name := strings.TrimSpace(trimmed[2 : len(trimmed)-2])
2300 return name, true, name != ""
2301 }
2302 if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
2303 name := strings.TrimSpace(trimmed[1 : len(trimmed)-1])
2304 return name, false, name != ""
2305 }
2306 return "", false, false
2307 }
2308
2309 func replaceTOMLTopLevelField(body, key, newLine string) string {
2310 spans := tomlLineSpans(body)
2311 insertAt := len(body)
2312 for _, span := range spans {
2313 if _, _, ok := tomlEditSectionHeader(span.text); ok {
2314 insertAt = span.start
2315 break
2316 }
2317 if got, ok := tomlTopLevelKey(span.text); ok && got == key {
2318 return body[:span.start] + newLine + body[span.end:]
2319 }
2320 }
2321 return body[:insertAt] + newLine + body[insertAt:]
2322 }
2323
2324 func tomlTopLevelKey(line string) (string, bool) {
2325 key, _, ok := tomlKeyValue(line)
2326 return key, ok
2327 }
2328
2329 func tomlKeyValue(line string) (string, string, bool) {
2330 trimmed := strings.TrimSpace(line)
2331 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
2332 return "", "", false
2333 }
2334 if before, _, ok := strings.Cut(trimmed, "#"); ok {
2335 trimmed = strings.TrimSpace(before)
2336 }
2337 key, value, ok := strings.Cut(trimmed, "=")
2338 if !ok {
2339 return "", "", false
2340 }
2341 key = strings.TrimSpace(key)
2342 if key == "" || strings.Contains(key, ".") {
2343 return "", "", false
2344 }
2345 return key, strings.TrimSpace(value), true
2346 }
2347
2348 func tomlSectionKeyValue(body, sectionName, key string) (string, bool) {
2349 inSection := false
2350 for _, span := range tomlLineSpans(body) {
2351 if name, isArray, ok := tomlEditSectionHeader(span.text); ok {
2352 inSection = !isArray && name == sectionName
2353 continue
2354 }
2355 if !inSection {
2356 continue
2357 }
2358 got, value, ok := tomlKeyValue(span.text)
2359 if ok && got == key {
2360 return value, true
2361 }
2362 }
2363 return "", false
2364 }
2365
2366 func tomlStringLiteralEquals(value, want string) bool {
2367 value = strings.TrimSpace(value)
2368 if len(value) >= 2 {
2369 quote := value[0]
2370 if (quote == '"' || quote == '\'') && value[len(value)-1] == quote {
2371 return value[1:len(value)-1] == want
2372 }
2373 }
2374 return value == want
2375 }
2376
2377 func tomlBodyHasTopLevelKey(body, key string) bool {
2378 for _, span := range tomlLineSpans(body) {
2379 if _, _, ok := tomlEditSectionHeader(span.text); ok {
2380 return false
2381 }
2382 if got, ok := tomlTopLevelKey(span.text); ok && got == key {
2383 return true
2384 }
2385 }
2386 return false
2387 }
2388
2389 func tomlBodyHasSection(body, sectionName string) bool {
2390 for _, span := range tomlLineSpans(body) {
2391 name, _, ok := tomlEditSectionHeader(span.text)
2392 if ok && name == sectionName {
2393 return true
2394 }
2395 }
2396 return false
2397 }
2398
2398 lines GO