返回 DeepSeek-Reasonix
collect.go
根目录 / internal / capdiag / collect.go
1 package capdiag
2
3 import (
4 "os"
5 "path/filepath"
6 "sort"
7 "strings"
8 "time"
9
10 "reasonix/internal/command"
11 "reasonix/internal/config"
12 "reasonix/internal/hook"
13 "reasonix/internal/memory"
14 "reasonix/internal/plugin"
15 "reasonix/internal/pluginpkg"
16 "reasonix/internal/secrets"
17 "reasonix/internal/skill"
18 )
19
20 // Collect builds a capability diagnostics report. It never writes config,
21 // cache, state, or log files. Live MCP is opt-in via Options.Live.
22 func Collect(opts Options) Report {
23 root := opts.Root
24 if root == "" {
25 if wd, err := os.Getwd(); err == nil {
26 root = wd
27 } else {
28 root = "."
29 }
30 }
31 if abs, err := filepath.Abs(root); err == nil {
32 root = abs
33 }
34
35 home := opts.HomeDir
36 if home == "" {
37 if h, err := os.UserHomeDir(); err == nil {
38 home = h
39 }
40 }
41 reasonixHome := opts.ReasonixHomeDir
42 if reasonixHome == "" {
43 if opts.HomeDir != "" {
44 reasonixHome = filepath.Join(home, ".reasonix")
45 } else {
46 reasonixHome = config.ReasonixHomeDir()
47 }
48 }
49
50 // Read-only load: never rewrite legacy tier lines or other config on disk.
51 cfg, cfgErr := config.LoadForRootReadOnly(root)
52 if cfg == nil {
53 cfg = config.Default()
54 }
55
56 issues := []Issue{}
57 if cfgErr != nil {
58 issues = append(issues, Issue{
59 Severity: "error", Code: "config.load_failed", Subsystem: "config",
60 Message: "failed to load configuration: " + sanitizeErrTextWithPaths(cfgErr.Error(), root, home, reasonixHome),
61 Remediation: "Fix reasonix.toml / config.toml syntax, then re-run doctor capabilities",
62 })
63 }
64
65 disp := func(p string) string { return displayPath(p, root, home, reasonixHome) }
66
67 instr, instructionIssues := collectInstructions(root, home, disp)
68 skillsR, skillIssues, skillStore := collectSkills(root, home, reasonixHome, cfg, disp)
69 cmdsR, cmdIssues := collectCommands(root, disp)
70 hooksR, hookIssues := collectHooks(root, home, reasonixHome, cfg, disp)
71 pluginsR, pluginIssues := collectPlugins(reasonixHome, disp)
72 mcpR, mcpIssues := collectMCP(cfg, root, home, reasonixHome, disp)
73
74 issues = append(issues, instructionIssues...)
75 issues = append(issues, skillIssues...)
76 issues = append(issues, cmdIssues...)
77 issues = append(issues, hookIssues...)
78 issues = append(issues, pluginIssues...)
79 issues = append(issues, mcpIssues...)
80
81 // Runtime host merge (desktop) or live probe (CLI).
82 if opts.Live {
83 liveIssues := probeLiveMCP(&mcpR, cfg, root, home, reasonixHome, opts.LiveTimeout)
84 issues = append(issues, liveIssues...)
85 } else if opts.RuntimeHost != nil {
86 mergeRuntimeHost(&mcpR, opts.RuntimeHost, root, home, reasonixHome, &issues)
87 }
88
89 issues = append(issues, skillToolIssues(skillStore, cfg, mcpR, func(message string) string {
90 return sanitizeErrTextWithPaths(message, root, home, reasonixHome)
91 })...)
92 sortIssues(issues)
93 report := Report{
94 SchemaVersion: SchemaVersion,
95 Root: disp(root),
96 Live: opts.Live,
97 Instructions: instr,
98 Skills: skillsR,
99 Commands: cmdsR,
100 Hooks: hooksR,
101 Plugins: pluginsR,
102 MCP: mcpR,
103 Issues: issues,
104 }
105 report.Summary = buildSummary(report)
106 return report
107 }
108
109 // CollectWithRuntimeUnavailable adds mcp.runtime_unavailable when desktop
110 // requested session runtime but no Host is available.
111 func CollectWithRuntimeUnavailable(opts Options) Report {
112 r := Collect(opts)
113 if opts.RuntimeHost == nil && !opts.Live {
114 r.Issues = append(r.Issues, Issue{
115 Severity: "info", Code: "mcp.runtime_unavailable", Subsystem: "mcp",
116 Message: "no active session Host; showing static configuration only",
117 Remediation: "Open or select a workspace chat tab, then refresh with session runtime enabled",
118 SettingsTab: "mcp",
119 })
120 sortIssues(r.Issues)
121 r.Summary = buildSummary(r)
122 }
123 return r
124 }
125
126 func buildSummary(r Report) Summary {
127 s := Summary{
128 Instructions: len(r.Instructions.Docs),
129 Skills: r.Skills.Winners,
130 Commands: r.Commands.Winners,
131 Hooks: len(r.Hooks.Entries),
132 Plugins: len(r.Plugins.Packages),
133 MCPServers: len(r.MCP.Servers),
134 }
135 for _, is := range r.Issues {
136 switch is.Severity {
137 case "error":
138 s.Errors++
139 case "warning":
140 s.Warnings++
141 default:
142 s.Infos++
143 }
144 }
145 return s
146 }
147
148 func collectInstructions(root, home string, disp func(string) string) (InstructionsReport, []Issue) {
149 userDir := config.MemoryUserDir()
150 if home != "" && (userDir == "" || strings.Contains(userDir, home)) {
151 // Prefer explicit test home when Reasonix home is under it.
152 if custom := filepath.Join(home, ".reasonix"); custom != "" {
153 if userDir == "" {
154 userDir = custom
155 }
156 }
157 }
158 set := memory.Load(memory.Options{CWD: root, UserDir: userDir})
159 out := InstructionsReport{Docs: []InstructionDoc{}}
160 if set == nil {
161 return out, nil
162 }
163 for i, d := range set.Docs {
164 out.Docs = append(out.Docs, InstructionDoc{
165 Path: disp(d.Path), Scope: string(d.Scope), Directory: disp(d.Directory),
166 Depth: d.Depth, Order: i + 1,
167 })
168 }
169 issues := make([]Issue, 0, len(set.InstructionDiagnostics))
170 for _, diagnostic := range set.InstructionDiagnostics {
171 source := diagnostic.SourcePath
172 if source == "" {
173 source = diagnostic.Path
174 }
175 issues = append(issues, Issue{
176 Severity: "warning", Code: "instruction." + diagnostic.Code, Subsystem: "instructions",
177 Source: disp(source), Message: diagnostic.Message,
178 Remediation: "Fix or remove the referenced instruction import, then start a new session",
179 SettingsTab: "memory",
180 })
181 }
182 return out, issues
183 }
184
185 func collectSkills(root, home, reasonixHome string, cfg *config.Config, disp func(string) string) (AssetReport, []Issue, *skill.Store) {
186 var issues []Issue
187 store := skill.DiagnosticStore(root, home, reasonixHome, cfg)
188 insp := store.Inspect()
189 rep := AssetReport{Roots: []RootInfo{}, Entries: []AssetEntry{}}
190 for _, r := range insp.Roots {
191 rep.Roots = append(rep.Roots, RootInfo{
192 Path: disp(r.Dir), Scope: string(r.Scope), Status: string(r.Status),
193 })
194 }
195 for _, c := range insp.Candidates {
196 ent := AssetEntry{
197 Name: c.Name, Description: c.Description, Scope: string(c.Scope),
198 Path: disp(c.Path), Status: string(c.Status), RunAs: string(c.RunAs),
199 }
200 if c.WinnerPath != "" {
201 ent.WinnerPath = disp(c.WinnerPath)
202 }
203 rep.Entries = append(rep.Entries, ent)
204 switch c.Status {
205 case skill.CandidateWinner:
206 rep.Winners++
207 if skill.MissingDescription(c.Description) {
208 issues = append(issues, Issue{
209 Severity: "warning", Code: "skill.missing_description", Subsystem: "skills",
210 Name: c.Name, Source: disp(c.Path),
211 Message: "skill has no description frontmatter; index quality is reduced",
212 Remediation: "Add a one-line description: field to the skill frontmatter",
213 SettingsTab: "skills",
214 })
215 }
216 case skill.CandidateShadowed:
217 rep.Shadowed++
218 issues = append(issues, Issue{
219 Severity: "info", Code: "skill.shadowed", Subsystem: "skills",
220 Name: c.Name, Source: disp(c.Path),
221 Message: "skill is shadowed by a higher-priority winner at " + disp(c.WinnerPath),
222 Remediation: "Rename, remove, or disable the lower-priority skill if the winner is unintended",
223 SettingsTab: "skills",
224 })
225 case skill.CandidateDisabled:
226 rep.Disabled++
227 issues = append(issues, Issue{
228 Severity: "info", Code: "skill.disabled", Subsystem: "skills",
229 Name: c.Name, Source: disp(c.Path),
230 Message: "skill is listed in disabled_skills and will not load",
231 Remediation: "Remove the name from [skills].disabled_skills to re-enable",
232 SettingsTab: "skills",
233 })
234 }
235 }
236 return rep, issues, store
237 }
238
239 func collectCommands(root string, disp func(string) string) (AssetReport, []Issue) {
240 var issues []Issue
241 dirs := config.CommandDirsForRoot(root)
242 insp := command.Inspect(dirs...)
243 rep := AssetReport{Roots: []RootInfo{}, Entries: []AssetEntry{}}
244 for _, r := range insp.Roots {
245 rep.Roots = append(rep.Roots, RootInfo{Path: disp(r.Dir), Status: r.Status})
246 }
247 for _, c := range insp.Candidates {
248 ent := AssetEntry{
249 Name: c.Name, Description: c.Description, Path: disp(c.Path), Status: string(c.Status),
250 }
251 if c.WinnerPath != "" {
252 ent.WinnerPath = disp(c.WinnerPath)
253 }
254 if c.Error != "" {
255 ent.Error = sanitizeErrText(c.Error)
256 }
257 rep.Entries = append(rep.Entries, ent)
258 switch c.Status {
259 case command.CandidateWinner:
260 rep.Winners++
261 case command.CandidateShadowed:
262 rep.Shadowed++
263 issues = append(issues, Issue{
264 Severity: "info", Code: "command.shadowed", Subsystem: "commands",
265 Name: c.Name, Source: disp(c.Path),
266 Message: "command is overridden by later directory winner at " + disp(c.WinnerPath),
267 Remediation: "Remove or rename the earlier command file if the override is unintended",
268 })
269 case command.CandidateError:
270 rep.ParseErrors++
271 issues = append(issues, Issue{
272 Severity: "error", Code: "command.read_failed", Subsystem: "commands",
273 Name: c.Name, Source: disp(c.Path),
274 Message: "failed to read or parse command file",
275 Remediation: "Fix file permissions/encoding or remove the broken file",
276 })
277 }
278 }
279 return rep, issues
280 }
281
282 func collectHooks(root, home, reasonixHome string, cfg *config.Config, disp func(string) string) (HookReport, []Issue) {
283 var issues []Issue
284 insp := hook.Inspect(hook.LoadOptions{
285 ProjectRoot: root,
286 HomeDir: home,
287 ReasonixHomeDir: reasonixHome,
288 })
289 runtimeOptions := hook.RuntimeOptions{}
290 if cfg != nil {
291 runtimeOptions = hook.RuntimeOptionsForShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path)
292 }
293 rep := HookReport{
294 // Retained in schema v1 for compatibility; project hooks are enabled by
295 // default whenever a project root is present.
296 TrustedProject: insp.TrustedProject,
297 ProjectDefines: insp.ProjectDefines,
298 Sources: []HookSource{},
299 Entries: []HookEntry{},
300 }
301 for _, s := range insp.Sources {
302 rep.Sources = append(rep.Sources, HookSource{
303 Scope: string(s.Scope), Path: disp(s.Path), Status: s.Status,
304 HookCount: s.HookCount, ParseError: sanitizeErrText(s.ParseError),
305 })
306 if s.Status == "malformed" {
307 issues = append(issues, Issue{
308 Severity: "error", Code: "hook.malformed_settings", Subsystem: "hooks",
309 Source: disp(s.Path),
310 Message: "hooks settings JSON is malformed",
311 Remediation: "Fix JSON syntax in the settings file",
312 SettingsTab: "hooks",
313 })
314 }
315 }
316 for _, e := range insp.Entries {
317 rep.Entries = append(rep.Entries, HookEntry{
318 Event: string(e.Event), Match: e.Match, Command: redactCommandDisplay(e.Command, root, home, reasonixHome),
319 ContextFile: disp(e.ContextFile), Description: e.Description, TimeoutMS: e.Timeout,
320 Scope: string(e.Scope), Source: disp(e.Source), Blocking: hook.IsBlocking(e.Event),
321 })
322 if strings.TrimSpace(e.Command) == "" && strings.TrimSpace(e.ContextFile) == "" {
323 issues = append(issues, Issue{
324 Severity: "error", Code: "hook.missing_command", Subsystem: "hooks",
325 Name: string(e.Event), Source: disp(e.Source),
326 Message: "hook entry has neither command nor contextFile",
327 Remediation: "Set command or contextFile for the hook entry",
328 SettingsTab: "hooks",
329 })
330 }
331 if issue, ok := hookRuntimeIssue(e, hook.CheckEntryRuntime(e, runtimeOptions), disp); ok {
332 issues = append(issues, issue)
333 }
334 if e.ContextFile != "" {
335 if !hook.ContextFileUsable(e.ContextFile) {
336 issues = append(issues, Issue{
337 Severity: "error", Code: "hook.missing_context_file", Subsystem: "hooks",
338 Name: string(e.Event), Source: disp(e.ContextFile),
339 Message: "hook contextFile is missing or unreadable",
340 Remediation: "Create a readable regular context file or fix the path in the hook entry",
341 SettingsTab: "hooks",
342 })
343 }
344 }
345 if msg := hook.ValidateMatcher(e.Match); hook.UsesToolMatcher(e.Event) && msg != "" {
346 issues = append(issues, Issue{
347 Severity: "error", Code: "hook.invalid_matcher", Subsystem: "hooks",
348 Name: string(e.Event), Source: disp(e.Source),
349 Message: msg,
350 Remediation: "Use an anchored regex (or empty/*); remember matchers are fully anchored",
351 SettingsTab: "hooks",
352 })
353 }
354 if !hook.IsKnownEvent(string(e.Event)) {
355 issues = append(issues, Issue{
356 Severity: "warning", Code: "hook.unknown_event", Subsystem: "hooks",
357 Name: string(e.Event), Source: disp(e.Source),
358 Message: "hook event is not one of the 11 supported events",
359 Remediation: "Use a supported event name from the hooks documentation",
360 SettingsTab: "hooks",
361 })
362 }
363 }
364 return rep, issues
365 }
366
367 func hookRuntimeIssue(entry hook.Entry, err error, disp func(string) string) (Issue, bool) {
368 if err == nil {
369 return Issue{}, false
370 }
371 return Issue{
372 Severity: "error", Code: "hook.shell_unavailable", Subsystem: "hooks",
373 Name: string(entry.Event), Source: disp(entry.Source),
374 Message: sanitizeErrText(err.Error()),
375 Remediation: "Install Git for Windows, or configure [tools.shell] prefer=\"bash\" and path to a usable bash.exe, then re-run doctor capabilities",
376 SettingsTab: "hooks",
377 }, true
378 }
379
380 func collectPlugins(reasonixHome string, disp func(string) string) (PluginPackageReport, []Issue) {
381 var issues []Issue
382 rep := PluginPackageReport{
383 StatePath: disp(filepath.Join(reasonixHome, pluginpkg.StateFilename)),
384 Packages: []PluginPackageInfo{},
385 }
386 st, err := pluginpkg.LoadState(reasonixHome)
387 if err != nil {
388 issues = append(issues, Issue{
389 Severity: "error", Code: "plugin.state_read_failed", Subsystem: "plugins",
390 Source: rep.StatePath,
391 Message: "failed to read plugin-packages state",
392 Remediation: "Ensure Reasonix home is readable or reinstall packages",
393 SettingsTab: "plugins",
394 })
395 return rep, issues
396 }
397 // Stable order by name.
398 sort.SliceStable(st.Plugins, func(i, j int) bool {
399 return st.Plugins[i].Name < st.Plugins[j].Name
400 })
401 for _, p := range st.Plugins {
402 info := PluginPackageInfo{
403 Name: p.Name, Enabled: p.Enabled, Version: p.Version,
404 Root: disp(pluginpkg.ResolveRoot(reasonixHome, p.Root)),
405 ManifestKind: p.ManifestKind, Status: "ok",
406 }
407 root := pluginpkg.ResolveRoot(reasonixHome, p.Root)
408 if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
409 info.Status = "missing_root"
410 issues = append(issues, Issue{
411 Severity: "error", Code: "plugin.missing_root", Subsystem: "plugins",
412 Name: p.Name, Source: disp(root),
413 Message: "plugin package root directory is missing",
414 Remediation: "Reinstall the plugin package or remove it from plugin-packages.json",
415 SettingsTab: "plugins",
416 })
417 rep.Packages = append(rep.Packages, info)
418 continue
419 }
420 pkg, warnings, perr := pluginpkg.ParseDir(root)
421 if perr != nil {
422 info.Status = "invalid_manifest"
423 issues = append(issues, Issue{
424 Severity: "error", Code: "plugin.invalid_manifest", Subsystem: "plugins",
425 Name: p.Name, Source: disp(root),
426 Message: "plugin package manifest is invalid: " + sanitizeErr(perr),
427 Remediation: "Fix reasonix-plugin.json / Codex / Claude plugin manifest",
428 SettingsTab: "plugins",
429 })
430 rep.Packages = append(rep.Packages, info)
431 continue
432 }
433 sk, commands, hk, mcp := pkg.CapabilityCounts()
434 info.Skills, info.Commands, info.Hooks, info.MCPServers = sk, commands, hk, mcp
435 info.Prompts, info.Themes = pkg.PromptCount(), pkg.ThemeCount()
436 info.Runtime = pkg.Manifest.Runtime != nil
437 if p.ManifestKind == "" {
438 info.ManifestKind = pkg.ManifestKind
439 }
440 for _, w := range warnings {
441 info.Warnings = append(info.Warnings, w)
442 issues = append(issues, Issue{
443 Severity: "warning", Code: "plugin.compatibility", Subsystem: "plugins",
444 Name: p.Name, Source: disp(root),
445 Message: w,
446 Remediation: "Review plugin package documentation for unsupported Claude/Codex features",
447 SettingsTab: "plugins",
448 })
449 }
450 if !p.Enabled {
451 info.Status = "disabled"
452 }
453 rep.Packages = append(rep.Packages, info)
454 }
455 return rep, issues
456 }
457
458 func collectMCP(cfg *config.Config, root, home, reasonixHome string, disp func(string) string) (MCPReport, []Issue) {
459 var issues []Issue
460 rep := MCPReport{Servers: []MCPServerInfo{}}
461 if cfg == nil {
462 return rep, issues
463 }
464 // Stable order by name.
465 entries := append([]config.PluginEntry(nil), cfg.Plugins...)
466 sort.SliceStable(entries, func(i, j int) bool {
467 return entries[i].Name < entries[j].Name
468 })
469 for _, p := range entries {
470 info := MCPServerInfo{
471 Name: p.Name,
472 Effective: true,
473 Transport: transportOf(p.Type),
474 StartIntent: "automatic",
475 EnvKeys: sortedKeys(p.Env),
476 HeaderKeys: sortedKeys(p.Headers),
477 }
478 if !p.ShouldAutoStart() {
479 info.StartIntent = "off"
480 }
481 if owner, ok := cfg.PluginPackageOwner(p.Name); ok {
482 info.PackageOwner = owner
483 info.Source = "plugin_package"
484 } else {
485 info.Source = strings.TrimSpace(string(p.Source))
486 if info.Source == "" {
487 info.Source = guessMCPSource(root, p.Name)
488 }
489 if sourcePath := config.MCPConfigPathForEntry(root, p); strings.TrimSpace(sourcePath) != "" {
490 info.SourcePath = disp(sourcePath)
491 }
492 }
493 if info.Transport == "stdio" {
494 info.Command = redactCommandDisplay(p.Command, root, home, reasonixHome)
495 } else {
496 info.URLHost = urlHostOnly(p.URL)
497 }
498 if !isValidTransport(p.Type) && strings.TrimSpace(p.Type) != "" {
499 issues = append(issues, Issue{
500 Severity: "error", Code: "mcp.invalid_transport", Subsystem: "mcp",
501 Name: p.Name, Source: info.Source,
502 Message: "unsupported MCP transport " + p.Type,
503 Remediation: "Use type stdio, http, or sse",
504 SettingsTab: "mcp",
505 })
506 }
507 if transportOf(p.Type) == "stdio" {
508 if strings.TrimSpace(p.Command) == "" {
509 issues = append(issues, Issue{
510 Severity: "error", Code: "mcp.missing_command", Subsystem: "mcp",
511 Name: p.Name, Source: info.Source,
512 Message: "stdio MCP server has empty command",
513 Remediation: "Set command (and optional args) for the server",
514 SettingsTab: "mcp",
515 })
516 } else if !commandExists(p.Command) {
517 // Static LookPath cannot mirror GUI/login-shell PATH enrichment used
518 // at runtime; treat as warning so diagnostics do not hard-fail a
519 // command that may still start under the real session environment.
520 issues = append(issues, Issue{
521 Severity: "warning", Code: "mcp.command_not_found", Subsystem: "mcp",
522 Name: p.Name, Source: info.Source,
523 Message: "MCP command is not found via static PATH lookup (GUI/login-shell PATH may still resolve it at runtime)",
524 Remediation: "Use an absolute command path, set PATH in the server env, or verify with session runtime / --live",
525 SettingsTab: "mcp",
526 })
527 }
528 } else if strings.TrimSpace(p.URL) == "" {
529 issues = append(issues, Issue{
530 Severity: "error", Code: "mcp.missing_url", Subsystem: "mcp",
531 Name: p.Name, Source: info.Source,
532 Message: "remote MCP server has empty url",
533 Remediation: "Set a valid http(s) URL for the server",
534 SettingsTab: "mcp",
535 })
536 }
537 rep.Servers = append(rep.Servers, info)
538 }
539 return rep, issues
540 }
541
542 func guessMCPSource(root, name string) string {
543 // Best-effort: check project .mcp.json presence of the name without loading secrets.
544 mcpPath := filepath.Join(root, ".mcp.json")
545 if b, err := os.ReadFile(mcpPath); err == nil {
546 // Cheap substring check; false positives are acceptable for diagnostics.
547 if strings.Contains(string(b), `"`+name+`"`) {
548 return "mcp_json"
549 }
550 }
551 return "toml"
552 }
553
554 func commandExists(cmd string) bool {
555 cmd = strings.TrimSpace(cmd)
556 if cmd == "" {
557 return false
558 }
559 if filepath.IsAbs(cmd) {
560 st, err := os.Stat(cmd)
561 return err == nil && !st.IsDir()
562 }
563 // Relative path or PATH lookup — use LookPath.
564 if _, err := lookPath(cmd); err == nil {
565 return true
566 }
567 // Relative workspace path.
568 if st, err := os.Stat(cmd); err == nil && !st.IsDir() {
569 return true
570 }
571 return false
572 }
573
574 func mergeRuntimeHost(rep *MCPReport, host *plugin.Host, root, home, reasonixHome string, issues *[]Issue) {
575 if host == nil {
576 return
577 }
578 byName := map[string]int{}
579 for i, s := range rep.Servers {
580 byName[s.Name] = i
581 }
582 for _, s := range host.Servers() {
583 rep.bindings = append(rep.bindings, s.ToolBindings...)
584 tools := make([]MCPToolInfo, 0, len(s.ToolList))
585 for _, t := range s.ToolList {
586 tools = append(tools, MCPToolInfo{Name: t.Name, ReadOnlyHint: t.ReadOnlyHint, DestructiveHint: t.DestructiveHint})
587 }
588 if i, ok := byName[s.Name]; ok {
589 rep.Servers[i].RuntimeStatus = "connected"
590 rep.Servers[i].ToolCount = s.Tools
591 rep.Servers[i].Tools = tools
592 // Only warn when the server advertised a tools capability but listed none.
593 if s.HasTools && s.Tools == 0 {
594 *issues = append(*issues, Issue{
595 Severity: "warning", Code: "mcp.no_tools", Subsystem: "mcp",
596 Name: s.Name, Message: "MCP server connected but exposes no tools",
597 Remediation: "Check server configuration and authentication",
598 SettingsTab: "mcp",
599 })
600 }
601 } else {
602 rep.Servers = append(rep.Servers, MCPServerInfo{
603 Name: s.Name, Transport: s.Transport, RuntimeStatus: "connected",
604 ToolCount: s.Tools, Tools: tools, StartIntent: "automatic",
605 })
606 }
607 }
608 for _, f := range host.Failures() {
609 errText := sanitizeErrTextWithPaths(f.Error, root, home, reasonixHome)
610 if i, ok := byName[f.Name]; ok {
611 rep.Servers[i].RuntimeStatus = "failed"
612 rep.Servers[i].Error = errText
613 rep.Servers[i].StartupStage = f.Stage
614 rep.Servers[i].StartupElapsedMS = f.Elapsed.Milliseconds()
615 rep.Servers[i].Stderr = sanitizeErrTextWithPaths(f.Stderr, root, home, reasonixHome)
616 }
617 *issues = append(*issues, Issue{
618 Severity: "error", Code: "mcp.start_failed", Subsystem: "mcp",
619 Name: f.Name, Message: "MCP server failed in the current session: " + errText,
620 Remediation: "Inspect server logs, command/URL, and authentication; retry from Settings → MCP",
621 SettingsTab: "mcp",
622 })
623 }
624 for _, name := range host.ConnectingServers() {
625 if i, ok := byName[name]; ok && rep.Servers[i].RuntimeStatus == "" {
626 rep.Servers[i].RuntimeStatus = "deferred"
627 }
628 }
629 // Mark auto_start=off with empty runtime as disabled.
630 for i := range rep.Servers {
631 if rep.Servers[i].RuntimeStatus == "" && rep.Servers[i].StartIntent == "off" {
632 rep.Servers[i].RuntimeStatus = "disabled"
633 }
634 }
635 }
636
637 func sortIssues(issues []Issue) {
638 sev := map[string]int{"error": 0, "warning": 1, "info": 2}
639 sort.SliceStable(issues, func(i, j int) bool {
640 if sev[issues[i].Severity] != sev[issues[j].Severity] {
641 return sev[issues[i].Severity] < sev[issues[j].Severity]
642 }
643 if issues[i].Code != issues[j].Code {
644 return issues[i].Code < issues[j].Code
645 }
646 if issues[i].Name != issues[j].Name {
647 return issues[i].Name < issues[j].Name
648 }
649 return issues[i].Source < issues[j].Source
650 })
651 }
652
653 func sanitizeErr(err error) string {
654 if err == nil {
655 return ""
656 }
657 return sanitizeErrText(err.Error())
658 }
659
660 // sanitizeErrText redacts secrets and machine-local identity from diagnostic
661 // strings. Prefer sanitizeErrTextWithPaths when workspace/home are known.
662 func sanitizeErrText(s string) string {
663 return sanitizeErrTextWithPaths(s, "", "", "")
664 }
665
666 func sanitizeErrTextWithPaths(s, workspace, home, reasonixHome string) string {
667 s = strings.TrimSpace(s)
668 if s == "" {
669 return s
670 }
671 // Collapse whitespace so multi-line stderr is one line.
672 s = strings.Join(strings.Fields(s), " ")
673
674 // Strip URL query/fragment early.
675 if i := strings.IndexAny(s, "?#"); i >= 0 {
676 // Only cut when it looks like a URL fragment, not ordinary prose "?".
677 prefix := s[:i]
678 if strings.Contains(prefix, "://") || strings.Contains(strings.ToLower(prefix), "http") {
679 s = prefix
680 }
681 }
682
683 // PATH=... (often embedded in stdio resolve errors; not a credential, so
684 // the shared redactor below leaves it alone).
685 s = redactKeyValue(s, "PATH=")
686 s = redactKeyValue(s, "path=")
687
688 // Transport errors embed arbitrary HTTP bodies and stdio stderr, so run
689 // the product-wide credential recognizer (KEY=value and JSON
690 // "key":"value" forms, Authorization schemes, Cookie/Set-Cookie values,
691 // Bearer/JWT/vendor token shapes) instead of a second, narrower list.
692 s = secrets.Redact(s)
693
694 // The shared Bearer pattern only masks tokens of 16+ chars; diagnostics
695 // text can afford to redact shorter bearer tokens too.
696 s = redactBearer(s)
697
698 // Absolute paths: rewrite with displayPath when possible.
699 s = redactAbsolutePaths(s, workspace, home, reasonixHome)
700
701 // Cap length after redaction.
702 const max = 400
703 if len(s) > max {
704 s = s[:max] + "…"
705 }
706 return s
707 }
708
709 func redactKeyValue(s, key string) string {
710 var b strings.Builder
711 for {
712 i := strings.Index(s, key)
713 if i < 0 {
714 b.WriteString(s)
715 return b.String()
716 }
717 b.WriteString(s[:i])
718 b.WriteString(key)
719 b.WriteString("<redacted>")
720 rest := s[i+len(key):]
721 end := len(rest)
722 if j := strings.IndexAny(rest, " \t\n\r;,"); j >= 0 {
723 end = j
724 }
725 s = rest[end:]
726 }
727 }
728
729 func redactBearer(s string) string {
730 var b strings.Builder
731 lower := strings.ToLower(s)
732 const needle = "bearer "
733 for {
734 i := strings.Index(lower, needle)
735 if i < 0 {
736 b.WriteString(s)
737 return b.String()
738 }
739 b.WriteString(s[:i])
740 b.WriteString("Bearer <redacted>")
741 rest := s[i+len(needle):]
742 end := len(rest)
743 if j := strings.IndexAny(rest, " \t\n\r;,\"'"); j >= 0 {
744 end = j
745 }
746 s = rest[end:]
747 lower = strings.ToLower(s)
748 }
749 }
750
751 func redactAbsolutePaths(s, workspace, home, reasonixHome string) string {
752 // Walk for POSIX and Windows absolute path-like tokens.
753 var b strings.Builder
754 i := 0
755 for i < len(s) {
756 // Find candidate start: / or X:\
757 start := -1
758 if s[i] == '/' {
759 start = i
760 } else if i+2 < len(s) && ((s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z')) && s[i+1] == ':' && (s[i+2] == '\\' || s[i+2] == '/') {
761 start = i
762 }
763 if start < 0 {
764 b.WriteByte(s[i])
765 i++
766 continue
767 }
768 j := start + 1
769 for j < len(s) {
770 c := s[j]
771 if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '"' || c == '\'' || c == ',' || c == ';' || c == ')' || c == ']' {
772 break
773 }
774 j++
775 }
776 token := s[start:j]
777 // Only rewrite if it looks like a path with a directory separator beyond root.
778 if strings.ContainsAny(token, `/\`) && len(token) > 1 {
779 b.WriteString(displayPath(token, workspace, home, reasonixHome))
780 } else {
781 b.WriteString(token)
782 }
783 i = j
784 }
785 return b.String()
786 }
787
788 func redactCommandDisplay(cmd, root, home, reasonixHome string) string {
789 cmd = strings.TrimSpace(cmd)
790 if cmd == "" {
791 return ""
792 }
793 // Only show the command token, redacted if it looks like a path.
794 fields := strings.Fields(cmd)
795 if len(fields) == 0 {
796 return ""
797 }
798 return displayPath(fields[0], root, home, reasonixHome)
799 }
800
801 // DefaultLiveTimeout is used when --live is set without --timeout.
802 const DefaultLiveTimeout = 5 * time.Second
803
804 // MinLiveTimeout / MaxLiveTimeout bound --timeout.
805 const (
806 MinLiveTimeout = 1 * time.Second
807 MaxLiveTimeout = 60 * time.Second
808 )
809
809 lines GO