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