789 lines
25 KiB
Go
789 lines
25 KiB
Go
package capdiag
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"reasonix/internal/command"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/hook"
|
|
"reasonix/internal/memory"
|
|
"reasonix/internal/plugin"
|
|
"reasonix/internal/pluginpkg"
|
|
"reasonix/internal/secrets"
|
|
"reasonix/internal/skill"
|
|
)
|
|
|
|
// Collect builds a capability diagnostics report. It never writes config,
|
|
// cache, state, or log files. Live MCP is opt-in via Options.Live.
|
|
func Collect(opts Options) Report {
|
|
root := opts.Root
|
|
if root == "" {
|
|
if wd, err := os.Getwd(); err == nil {
|
|
root = wd
|
|
} else {
|
|
root = "."
|
|
}
|
|
}
|
|
if abs, err := filepath.Abs(root); err == nil {
|
|
root = abs
|
|
}
|
|
|
|
home := opts.HomeDir
|
|
if home == "" {
|
|
if h, err := os.UserHomeDir(); err == nil {
|
|
home = h
|
|
}
|
|
}
|
|
reasonixHome := opts.ReasonixHomeDir
|
|
if reasonixHome == "" {
|
|
if opts.HomeDir != "" {
|
|
reasonixHome = filepath.Join(home, ".reasonix")
|
|
} else {
|
|
reasonixHome = config.ReasonixHomeDir()
|
|
}
|
|
}
|
|
|
|
// Read-only load: never rewrite legacy tier lines or other config on disk.
|
|
cfg, cfgErr := config.LoadForRootReadOnly(root)
|
|
if cfg == nil {
|
|
cfg = config.Default()
|
|
}
|
|
|
|
issues := []Issue{}
|
|
if cfgErr != nil {
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "config.load_failed", Subsystem: "config",
|
|
Message: "failed to load configuration: " + sanitizeErrTextWithPaths(cfgErr.Error(), root, home),
|
|
Remediation: "Fix reasonix.toml / config.toml syntax, then re-run doctor capabilities",
|
|
})
|
|
}
|
|
|
|
disp := func(p string) string { return displayPath(p, root, home) }
|
|
|
|
instr := collectInstructions(root, home, disp)
|
|
skillsR, skillIssues := collectSkills(root, home, reasonixHome, cfg, disp)
|
|
cmdsR, cmdIssues := collectCommands(root, disp)
|
|
hooksR, hookIssues := collectHooks(root, home, reasonixHome, disp)
|
|
pluginsR, pluginIssues := collectPlugins(reasonixHome, disp)
|
|
mcpR, mcpIssues := collectMCP(cfg, root, home, disp)
|
|
|
|
issues = append(issues, skillIssues...)
|
|
issues = append(issues, cmdIssues...)
|
|
issues = append(issues, hookIssues...)
|
|
issues = append(issues, pluginIssues...)
|
|
issues = append(issues, mcpIssues...)
|
|
|
|
// Runtime host merge (desktop) or live probe (CLI).
|
|
if opts.Live {
|
|
liveIssues := probeLiveMCP(&mcpR, cfg, root, home, opts.LiveTimeout)
|
|
issues = append(issues, liveIssues...)
|
|
} else if opts.RuntimeHost != nil {
|
|
mergeRuntimeHost(&mcpR, opts.RuntimeHost, root, home, &issues)
|
|
}
|
|
|
|
sortIssues(issues)
|
|
report := Report{
|
|
SchemaVersion: SchemaVersion,
|
|
Root: disp(root),
|
|
Live: opts.Live,
|
|
Instructions: instr,
|
|
Skills: skillsR,
|
|
Commands: cmdsR,
|
|
Hooks: hooksR,
|
|
Plugins: pluginsR,
|
|
MCP: mcpR,
|
|
Issues: issues,
|
|
}
|
|
report.Summary = buildSummary(report)
|
|
return report
|
|
}
|
|
|
|
// CollectWithRuntimeUnavailable adds mcp.runtime_unavailable when desktop
|
|
// requested session runtime but no Host is available.
|
|
func CollectWithRuntimeUnavailable(opts Options) Report {
|
|
r := Collect(opts)
|
|
if opts.RuntimeHost == nil && !opts.Live {
|
|
r.Issues = append(r.Issues, Issue{
|
|
Severity: "info", Code: "mcp.runtime_unavailable", Subsystem: "mcp",
|
|
Message: "no active session Host; showing static configuration only",
|
|
Remediation: "Open or select a workspace chat tab, then refresh with session runtime enabled",
|
|
SettingsTab: "mcp",
|
|
})
|
|
sortIssues(r.Issues)
|
|
r.Summary = buildSummary(r)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func buildSummary(r Report) Summary {
|
|
s := Summary{
|
|
Instructions: len(r.Instructions.Docs),
|
|
Skills: r.Skills.Winners,
|
|
Commands: r.Commands.Winners,
|
|
Hooks: len(r.Hooks.Entries),
|
|
Plugins: len(r.Plugins.Packages),
|
|
MCPServers: len(r.MCP.Servers),
|
|
}
|
|
for _, is := range r.Issues {
|
|
switch is.Severity {
|
|
case "error":
|
|
s.Errors++
|
|
case "warning":
|
|
s.Warnings++
|
|
default:
|
|
s.Infos++
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func collectInstructions(root, home string, disp func(string) string) InstructionsReport {
|
|
userDir := config.MemoryUserDir()
|
|
if home != "" && (userDir == "" || strings.Contains(userDir, home)) {
|
|
// Prefer explicit test home when Reasonix home is under it.
|
|
if custom := filepath.Join(home, ".reasonix"); custom != "" {
|
|
if userDir == "" {
|
|
userDir = custom
|
|
}
|
|
}
|
|
}
|
|
set := memory.Load(memory.Options{CWD: root, UserDir: userDir})
|
|
out := InstructionsReport{Docs: []InstructionDoc{}}
|
|
if set == nil {
|
|
return out
|
|
}
|
|
for i, d := range set.Docs {
|
|
out.Docs = append(out.Docs, InstructionDoc{
|
|
Path: disp(d.Path),
|
|
Scope: string(d.Scope),
|
|
Order: i + 1,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func collectSkills(root, home, reasonixHome string, cfg *config.Config, disp func(string) string) (AssetReport, []Issue) {
|
|
var issues []Issue
|
|
store := skill.New(skill.Options{
|
|
HomeDir: home,
|
|
ReasonixHomeDir: reasonixHome,
|
|
ProjectRoot: root,
|
|
CustomPaths: cfg.SkillCustomPaths(),
|
|
ExcludedPaths: cfg.SkillExcludedPaths(),
|
|
DisabledNames: cfg.DisabledSkillNames(),
|
|
MaxDepth: cfg.SkillMaxDepth(),
|
|
Stderr: ioDiscard(),
|
|
})
|
|
insp := store.Inspect()
|
|
rep := AssetReport{Roots: []RootInfo{}, Entries: []AssetEntry{}}
|
|
for _, r := range insp.Roots {
|
|
rep.Roots = append(rep.Roots, RootInfo{
|
|
Path: disp(r.Dir), Scope: string(r.Scope), Status: string(r.Status),
|
|
})
|
|
}
|
|
for _, c := range insp.Candidates {
|
|
ent := AssetEntry{
|
|
Name: c.Name, Description: c.Description, Scope: string(c.Scope),
|
|
Path: disp(c.Path), Status: string(c.Status), RunAs: string(c.RunAs),
|
|
}
|
|
if c.WinnerPath != "" {
|
|
ent.WinnerPath = disp(c.WinnerPath)
|
|
}
|
|
rep.Entries = append(rep.Entries, ent)
|
|
switch c.Status {
|
|
case skill.CandidateWinner:
|
|
rep.Winners++
|
|
if skill.MissingDescription(c.Description) {
|
|
issues = append(issues, Issue{
|
|
Severity: "warning", Code: "skill.missing_description", Subsystem: "skills",
|
|
Name: c.Name, Source: disp(c.Path),
|
|
Message: "skill has no description frontmatter; index quality is reduced",
|
|
Remediation: "Add a one-line description: field to the skill frontmatter",
|
|
SettingsTab: "skills",
|
|
})
|
|
}
|
|
case skill.CandidateShadowed:
|
|
rep.Shadowed++
|
|
issues = append(issues, Issue{
|
|
Severity: "info", Code: "skill.shadowed", Subsystem: "skills",
|
|
Name: c.Name, Source: disp(c.Path),
|
|
Message: "skill is shadowed by a higher-priority winner at " + disp(c.WinnerPath),
|
|
Remediation: "Rename, remove, or disable the lower-priority skill if the winner is unintended",
|
|
SettingsTab: "skills",
|
|
})
|
|
case skill.CandidateDisabled:
|
|
rep.Disabled++
|
|
issues = append(issues, Issue{
|
|
Severity: "info", Code: "skill.disabled", Subsystem: "skills",
|
|
Name: c.Name, Source: disp(c.Path),
|
|
Message: "skill is listed in disabled_skills and will not load",
|
|
Remediation: "Remove the name from [skills].disabled_skills to re-enable",
|
|
SettingsTab: "skills",
|
|
})
|
|
}
|
|
}
|
|
return rep, issues
|
|
}
|
|
|
|
func collectCommands(root string, disp func(string) string) (AssetReport, []Issue) {
|
|
var issues []Issue
|
|
dirs := config.CommandDirsForRoot(root)
|
|
insp := command.Inspect(dirs...)
|
|
rep := AssetReport{Roots: []RootInfo{}, Entries: []AssetEntry{}}
|
|
for _, r := range insp.Roots {
|
|
rep.Roots = append(rep.Roots, RootInfo{Path: disp(r.Dir), Status: r.Status})
|
|
}
|
|
for _, c := range insp.Candidates {
|
|
ent := AssetEntry{
|
|
Name: c.Name, Description: c.Description, Path: disp(c.Path), Status: string(c.Status),
|
|
}
|
|
if c.WinnerPath != "" {
|
|
ent.WinnerPath = disp(c.WinnerPath)
|
|
}
|
|
if c.Error != "" {
|
|
ent.Error = sanitizeErrText(c.Error)
|
|
}
|
|
rep.Entries = append(rep.Entries, ent)
|
|
switch c.Status {
|
|
case command.CandidateWinner:
|
|
rep.Winners++
|
|
case command.CandidateShadowed:
|
|
rep.Shadowed++
|
|
issues = append(issues, Issue{
|
|
Severity: "info", Code: "command.shadowed", Subsystem: "commands",
|
|
Name: c.Name, Source: disp(c.Path),
|
|
Message: "command is overridden by later directory winner at " + disp(c.WinnerPath),
|
|
Remediation: "Remove or rename the earlier command file if the override is unintended",
|
|
})
|
|
case command.CandidateError:
|
|
rep.ParseErrors++
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "command.read_failed", Subsystem: "commands",
|
|
Name: c.Name, Source: disp(c.Path),
|
|
Message: "failed to read or parse command file",
|
|
Remediation: "Fix file permissions/encoding or remove the broken file",
|
|
})
|
|
}
|
|
}
|
|
return rep, issues
|
|
}
|
|
|
|
func collectHooks(root, home, reasonixHome string, disp func(string) string) (HookReport, []Issue) {
|
|
var issues []Issue
|
|
// Prefer explicit home for trust/settings when tests isolate HOME.
|
|
homeDir := home
|
|
if reasonixHome != "" && home == "" {
|
|
homeDir = filepath.Dir(reasonixHome)
|
|
}
|
|
trusted := hook.IsTrusted(root, homeDir)
|
|
insp := hook.Inspect(hook.LoadOptions{
|
|
ProjectRoot: root,
|
|
HomeDir: homeDir,
|
|
Trusted: trusted,
|
|
})
|
|
rep := HookReport{
|
|
TrustedProject: insp.TrustedProject || trusted,
|
|
ProjectDefines: insp.ProjectDefines,
|
|
Sources: []HookSource{},
|
|
Entries: []HookEntry{},
|
|
}
|
|
for _, s := range insp.Sources {
|
|
rep.Sources = append(rep.Sources, HookSource{
|
|
Scope: string(s.Scope), Path: disp(s.Path), Status: s.Status,
|
|
HookCount: s.HookCount, ParseError: sanitizeErrText(s.ParseError),
|
|
})
|
|
if s.Status == "malformed" {
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "hook.malformed_settings", Subsystem: "hooks",
|
|
Source: disp(s.Path),
|
|
Message: "hooks settings JSON is malformed",
|
|
Remediation: "Fix JSON syntax in the settings file",
|
|
SettingsTab: "hooks",
|
|
})
|
|
}
|
|
if s.Status == "untrusted_skipped" && insp.ProjectDefines {
|
|
issues = append(issues, Issue{
|
|
Severity: "warning", Code: "hook.untrusted_project", Subsystem: "hooks",
|
|
Source: disp(s.Path),
|
|
Message: "project defines hooks but the workspace is not trusted",
|
|
Remediation: "Trust this project in Settings → Hooks (or CLI trust flow) before project hooks run",
|
|
SettingsTab: "hooks",
|
|
})
|
|
}
|
|
}
|
|
for _, e := range insp.Entries {
|
|
rep.Entries = append(rep.Entries, HookEntry{
|
|
Event: string(e.Event), Match: e.Match, Command: redactCommandDisplay(e.Command, root, home),
|
|
ContextFile: disp(e.ContextFile), Description: e.Description, TimeoutMS: e.Timeout,
|
|
Scope: string(e.Scope), Source: disp(e.Source), Blocking: hook.IsBlocking(e.Event),
|
|
})
|
|
if strings.TrimSpace(e.Command) == "" && strings.TrimSpace(e.ContextFile) == "" {
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "hook.missing_command", Subsystem: "hooks",
|
|
Name: string(e.Event), Source: disp(e.Source),
|
|
Message: "hook entry has neither command nor contextFile",
|
|
Remediation: "Set command or contextFile for the hook entry",
|
|
SettingsTab: "hooks",
|
|
})
|
|
}
|
|
if e.ContextFile != "" {
|
|
if _, err := os.Stat(e.ContextFile); err != nil {
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "hook.missing_context_file", Subsystem: "hooks",
|
|
Name: string(e.Event), Source: disp(e.ContextFile),
|
|
Message: "hook contextFile does not exist",
|
|
Remediation: "Create the context file or fix the path in the hook entry",
|
|
SettingsTab: "hooks",
|
|
})
|
|
}
|
|
}
|
|
if msg := hook.ValidateMatcher(e.Match); msg != "" {
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "hook.invalid_matcher", Subsystem: "hooks",
|
|
Name: string(e.Event), Source: disp(e.Source),
|
|
Message: msg,
|
|
Remediation: "Use an anchored regex (or empty/*); remember matchers are fully anchored",
|
|
SettingsTab: "hooks",
|
|
})
|
|
}
|
|
if !hook.IsKnownEvent(string(e.Event)) {
|
|
issues = append(issues, Issue{
|
|
Severity: "warning", Code: "hook.unknown_event", Subsystem: "hooks",
|
|
Name: string(e.Event), Source: disp(e.Source),
|
|
Message: "hook event is not one of the 11 supported events",
|
|
Remediation: "Use a supported event name from the hooks documentation",
|
|
SettingsTab: "hooks",
|
|
})
|
|
}
|
|
}
|
|
return rep, issues
|
|
}
|
|
|
|
func collectPlugins(reasonixHome string, disp func(string) string) (PluginPackageReport, []Issue) {
|
|
var issues []Issue
|
|
rep := PluginPackageReport{
|
|
StatePath: disp(filepath.Join(reasonixHome, pluginpkg.StateFilename)),
|
|
Packages: []PluginPackageInfo{},
|
|
}
|
|
st, err := pluginpkg.LoadState(reasonixHome)
|
|
if err != nil {
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "plugin.state_read_failed", Subsystem: "plugins",
|
|
Source: rep.StatePath,
|
|
Message: "failed to read plugin-packages state",
|
|
Remediation: "Ensure Reasonix home is readable or reinstall packages",
|
|
SettingsTab: "plugins",
|
|
})
|
|
return rep, issues
|
|
}
|
|
// Stable order by name.
|
|
sort.SliceStable(st.Plugins, func(i, j int) bool {
|
|
return st.Plugins[i].Name < st.Plugins[j].Name
|
|
})
|
|
for _, p := range st.Plugins {
|
|
info := PluginPackageInfo{
|
|
Name: p.Name, Enabled: p.Enabled, Version: p.Version,
|
|
Root: disp(pluginpkg.ResolveRoot(reasonixHome, p.Root)),
|
|
ManifestKind: p.ManifestKind, Status: "ok",
|
|
}
|
|
root := pluginpkg.ResolveRoot(reasonixHome, p.Root)
|
|
if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
|
|
info.Status = "missing_root"
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "plugin.missing_root", Subsystem: "plugins",
|
|
Name: p.Name, Source: disp(root),
|
|
Message: "plugin package root directory is missing",
|
|
Remediation: "Reinstall the plugin package or remove it from plugin-packages.json",
|
|
SettingsTab: "plugins",
|
|
})
|
|
rep.Packages = append(rep.Packages, info)
|
|
continue
|
|
}
|
|
pkg, warnings, perr := pluginpkg.ParseDir(root)
|
|
if perr != nil {
|
|
info.Status = "invalid_manifest"
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "plugin.invalid_manifest", Subsystem: "plugins",
|
|
Name: p.Name, Source: disp(root),
|
|
Message: "plugin package manifest is invalid: " + sanitizeErr(perr),
|
|
Remediation: "Fix reasonix-plugin.json / Codex / Claude plugin manifest",
|
|
SettingsTab: "plugins",
|
|
})
|
|
rep.Packages = append(rep.Packages, info)
|
|
continue
|
|
}
|
|
sk, commands, hk, mcp := pkg.CapabilityCounts()
|
|
info.Skills, info.Commands, info.Hooks, info.MCPServers = sk, commands, hk, mcp
|
|
if p.ManifestKind == "" {
|
|
info.ManifestKind = pkg.ManifestKind
|
|
}
|
|
for _, w := range warnings {
|
|
info.Warnings = append(info.Warnings, w)
|
|
issues = append(issues, Issue{
|
|
Severity: "warning", Code: "plugin.compatibility", Subsystem: "plugins",
|
|
Name: p.Name, Source: disp(root),
|
|
Message: w,
|
|
Remediation: "Review plugin package documentation for unsupported Claude/Codex features",
|
|
SettingsTab: "plugins",
|
|
})
|
|
}
|
|
if !p.Enabled {
|
|
info.Status = "disabled"
|
|
}
|
|
rep.Packages = append(rep.Packages, info)
|
|
}
|
|
return rep, issues
|
|
}
|
|
|
|
func collectMCP(cfg *config.Config, root, home string, disp func(string) string) (MCPReport, []Issue) {
|
|
var issues []Issue
|
|
rep := MCPReport{Servers: []MCPServerInfo{}}
|
|
if cfg == nil {
|
|
return rep, issues
|
|
}
|
|
// Stable order by name.
|
|
entries := append([]config.PluginEntry(nil), cfg.Plugins...)
|
|
sort.SliceStable(entries, func(i, j int) bool {
|
|
return entries[i].Name < entries[j].Name
|
|
})
|
|
for _, p := range entries {
|
|
info := MCPServerInfo{
|
|
Name: p.Name,
|
|
Transport: transportOf(p.Type),
|
|
StartIntent: "automatic",
|
|
EnvKeys: sortedKeys(p.Env),
|
|
HeaderKeys: sortedKeys(p.Headers),
|
|
}
|
|
if !p.ShouldAutoStart() {
|
|
info.StartIntent = "off"
|
|
}
|
|
if owner, ok := cfg.PluginPackageOwner(p.Name); ok {
|
|
info.PackageOwner = owner
|
|
info.Source = "plugin_package"
|
|
} else {
|
|
info.Source = guessMCPSource(root, p.Name)
|
|
}
|
|
if info.Transport == "stdio" {
|
|
info.Command = redactCommandDisplay(p.Command, root, home)
|
|
} else {
|
|
info.URLHost = urlHostOnly(p.URL)
|
|
}
|
|
if !isValidTransport(p.Type) && strings.TrimSpace(p.Type) != "" {
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "mcp.invalid_transport", Subsystem: "mcp",
|
|
Name: p.Name, Source: info.Source,
|
|
Message: "unsupported MCP transport " + p.Type,
|
|
Remediation: "Use type stdio, http, or sse",
|
|
SettingsTab: "mcp",
|
|
})
|
|
}
|
|
if transportOf(p.Type) == "stdio" {
|
|
if strings.TrimSpace(p.Command) == "" {
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "mcp.missing_command", Subsystem: "mcp",
|
|
Name: p.Name, Source: info.Source,
|
|
Message: "stdio MCP server has empty command",
|
|
Remediation: "Set command (and optional args) for the server",
|
|
SettingsTab: "mcp",
|
|
})
|
|
} else if !commandExists(p.Command) {
|
|
// Static LookPath cannot mirror GUI/login-shell PATH enrichment used
|
|
// at runtime; treat as warning so diagnostics do not hard-fail a
|
|
// command that may still start under the real session environment.
|
|
issues = append(issues, Issue{
|
|
Severity: "warning", Code: "mcp.command_not_found", Subsystem: "mcp",
|
|
Name: p.Name, Source: info.Source,
|
|
Message: "MCP command is not found via static PATH lookup (GUI/login-shell PATH may still resolve it at runtime)",
|
|
Remediation: "Use an absolute command path, set PATH in the server env, or verify with session runtime / --live",
|
|
SettingsTab: "mcp",
|
|
})
|
|
}
|
|
} else if strings.TrimSpace(p.URL) == "" {
|
|
issues = append(issues, Issue{
|
|
Severity: "error", Code: "mcp.missing_url", Subsystem: "mcp",
|
|
Name: p.Name, Source: info.Source,
|
|
Message: "remote MCP server has empty url",
|
|
Remediation: "Set a valid http(s) URL for the server",
|
|
SettingsTab: "mcp",
|
|
})
|
|
}
|
|
rep.Servers = append(rep.Servers, info)
|
|
}
|
|
return rep, issues
|
|
}
|
|
|
|
func guessMCPSource(root, name string) string {
|
|
// Best-effort: check project .mcp.json presence of the name without loading secrets.
|
|
mcpPath := filepath.Join(root, ".mcp.json")
|
|
if b, err := os.ReadFile(mcpPath); err == nil {
|
|
// Cheap substring check; false positives are acceptable for diagnostics.
|
|
if strings.Contains(string(b), `"`+name+`"`) {
|
|
return "mcp_json"
|
|
}
|
|
}
|
|
return "toml"
|
|
}
|
|
|
|
func commandExists(cmd string) bool {
|
|
cmd = strings.TrimSpace(cmd)
|
|
if cmd == "" {
|
|
return false
|
|
}
|
|
if filepath.IsAbs(cmd) {
|
|
st, err := os.Stat(cmd)
|
|
return err == nil && !st.IsDir()
|
|
}
|
|
// Relative path or PATH lookup — use LookPath.
|
|
if _, err := lookPath(cmd); err == nil {
|
|
return true
|
|
}
|
|
// Relative workspace path.
|
|
if st, err := os.Stat(cmd); err == nil && !st.IsDir() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mergeRuntimeHost(rep *MCPReport, host *plugin.Host, root, home string, issues *[]Issue) {
|
|
if host == nil {
|
|
return
|
|
}
|
|
byName := map[string]int{}
|
|
for i, s := range rep.Servers {
|
|
byName[s.Name] = i
|
|
}
|
|
for _, s := range host.Servers() {
|
|
tools := make([]MCPToolInfo, 0, len(s.ToolList))
|
|
for _, t := range s.ToolList {
|
|
tools = append(tools, MCPToolInfo{Name: t.Name, ReadOnlyHint: t.ReadOnlyHint})
|
|
}
|
|
if i, ok := byName[s.Name]; ok {
|
|
rep.Servers[i].RuntimeStatus = "connected"
|
|
rep.Servers[i].ToolCount = s.Tools
|
|
rep.Servers[i].Tools = tools
|
|
// Only warn when the server advertised a tools capability but listed none.
|
|
if s.HasTools && s.Tools == 0 {
|
|
*issues = append(*issues, Issue{
|
|
Severity: "warning", Code: "mcp.no_tools", Subsystem: "mcp",
|
|
Name: s.Name, Message: "MCP server connected but exposes no tools",
|
|
Remediation: "Check server configuration and authentication",
|
|
SettingsTab: "mcp",
|
|
})
|
|
}
|
|
} else {
|
|
rep.Servers = append(rep.Servers, MCPServerInfo{
|
|
Name: s.Name, Transport: s.Transport, RuntimeStatus: "connected",
|
|
ToolCount: s.Tools, Tools: tools, StartIntent: "automatic",
|
|
})
|
|
}
|
|
}
|
|
for _, f := range host.Failures() {
|
|
errText := sanitizeErrTextWithPaths(f.Error, root, home)
|
|
if i, ok := byName[f.Name]; ok {
|
|
rep.Servers[i].RuntimeStatus = "failed"
|
|
rep.Servers[i].Error = errText
|
|
}
|
|
*issues = append(*issues, Issue{
|
|
Severity: "error", Code: "mcp.start_failed", Subsystem: "mcp",
|
|
Name: f.Name, Message: "MCP server failed in the current session: " + errText,
|
|
Remediation: "Inspect server logs, command/URL, and authentication; retry from Settings → MCP",
|
|
SettingsTab: "mcp",
|
|
})
|
|
}
|
|
for _, name := range host.ConnectingServers() {
|
|
if i, ok := byName[name]; ok && rep.Servers[i].RuntimeStatus == "" {
|
|
rep.Servers[i].RuntimeStatus = "deferred"
|
|
}
|
|
}
|
|
// Mark auto_start=off with empty runtime as disabled.
|
|
for i := range rep.Servers {
|
|
if rep.Servers[i].RuntimeStatus == "" && rep.Servers[i].StartIntent == "off" {
|
|
rep.Servers[i].RuntimeStatus = "disabled"
|
|
}
|
|
}
|
|
}
|
|
|
|
func sortIssues(issues []Issue) {
|
|
sev := map[string]int{"error": 0, "warning": 1, "info": 2}
|
|
sort.SliceStable(issues, func(i, j int) bool {
|
|
if sev[issues[i].Severity] != sev[issues[j].Severity] {
|
|
return sev[issues[i].Severity] < sev[issues[j].Severity]
|
|
}
|
|
if issues[i].Code != issues[j].Code {
|
|
return issues[i].Code < issues[j].Code
|
|
}
|
|
if issues[i].Name != issues[j].Name {
|
|
return issues[i].Name < issues[j].Name
|
|
}
|
|
return issues[i].Source < issues[j].Source
|
|
})
|
|
}
|
|
|
|
func sanitizeErr(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return sanitizeErrText(err.Error())
|
|
}
|
|
|
|
// sanitizeErrText redacts secrets and machine-local identity from diagnostic
|
|
// strings. Prefer sanitizeErrTextWithPaths when workspace/home are known.
|
|
func sanitizeErrText(s string) string {
|
|
return sanitizeErrTextWithPaths(s, "", "")
|
|
}
|
|
|
|
func sanitizeErrTextWithPaths(s, workspace, home string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return s
|
|
}
|
|
// Collapse whitespace so multi-line stderr is one line.
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
|
|
// Strip URL query/fragment early.
|
|
if i := strings.IndexAny(s, "?#"); i >= 0 {
|
|
// Only cut when it looks like a URL fragment, not ordinary prose "?".
|
|
prefix := s[:i]
|
|
if strings.Contains(prefix, "://") || strings.Contains(strings.ToLower(prefix), "http") {
|
|
s = prefix
|
|
}
|
|
}
|
|
|
|
// PATH=... (often embedded in stdio resolve errors; not a credential, so
|
|
// the shared redactor below leaves it alone).
|
|
s = redactKeyValue(s, "PATH=")
|
|
s = redactKeyValue(s, "path=")
|
|
|
|
// Transport errors embed arbitrary HTTP bodies and stdio stderr, so run
|
|
// the product-wide credential recognizer (KEY=value and JSON
|
|
// "key":"value" forms, Authorization schemes, Cookie/Set-Cookie values,
|
|
// Bearer/JWT/vendor token shapes) instead of a second, narrower list.
|
|
s = secrets.Redact(s)
|
|
|
|
// The shared Bearer pattern only masks tokens of 16+ chars; diagnostics
|
|
// text can afford to redact shorter bearer tokens too.
|
|
s = redactBearer(s)
|
|
|
|
// Absolute paths: rewrite with displayPath when possible.
|
|
s = redactAbsolutePaths(s, workspace, home)
|
|
|
|
// Cap length after redaction.
|
|
const max = 400
|
|
if len(s) > max {
|
|
s = s[:max] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func redactKeyValue(s, key string) string {
|
|
var b strings.Builder
|
|
for {
|
|
i := strings.Index(s, key)
|
|
if i < 0 {
|
|
b.WriteString(s)
|
|
return b.String()
|
|
}
|
|
b.WriteString(s[:i])
|
|
b.WriteString(key)
|
|
b.WriteString("<redacted>")
|
|
rest := s[i+len(key):]
|
|
end := len(rest)
|
|
if j := strings.IndexAny(rest, " \t\n\r;,"); j >= 0 {
|
|
end = j
|
|
}
|
|
s = rest[end:]
|
|
}
|
|
}
|
|
|
|
func redactBearer(s string) string {
|
|
var b strings.Builder
|
|
lower := strings.ToLower(s)
|
|
const needle = "bearer "
|
|
for {
|
|
i := strings.Index(lower, needle)
|
|
if i < 0 {
|
|
b.WriteString(s)
|
|
return b.String()
|
|
}
|
|
b.WriteString(s[:i])
|
|
b.WriteString("Bearer <redacted>")
|
|
rest := s[i+len(needle):]
|
|
end := len(rest)
|
|
if j := strings.IndexAny(rest, " \t\n\r;,\"'"); j >= 0 {
|
|
end = j
|
|
}
|
|
s = rest[end:]
|
|
lower = strings.ToLower(s)
|
|
}
|
|
}
|
|
|
|
func redactAbsolutePaths(s, workspace, home string) string {
|
|
// Walk for POSIX and Windows absolute path-like tokens.
|
|
var b strings.Builder
|
|
i := 0
|
|
for i < len(s) {
|
|
// Find candidate start: / or X:\
|
|
start := -1
|
|
if s[i] == '/' {
|
|
start = i
|
|
} 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] == '/') {
|
|
start = i
|
|
}
|
|
if start < 0 {
|
|
b.WriteByte(s[i])
|
|
i++
|
|
continue
|
|
}
|
|
j := start + 1
|
|
for j < len(s) {
|
|
c := s[j]
|
|
if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '"' || c == '\'' || c == ',' || c == ';' || c == ')' || c == ']' {
|
|
break
|
|
}
|
|
j++
|
|
}
|
|
token := s[start:j]
|
|
// Only rewrite if it looks like a path with a directory separator beyond root.
|
|
if strings.ContainsAny(token, `/\`) && len(token) > 1 {
|
|
b.WriteString(displayPath(token, workspace, home))
|
|
} else {
|
|
b.WriteString(token)
|
|
}
|
|
i = j
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func redactCommandDisplay(cmd, root, home string) string {
|
|
cmd = strings.TrimSpace(cmd)
|
|
if cmd == "" {
|
|
return ""
|
|
}
|
|
// Only show the command token, redacted if it looks like a path.
|
|
fields := strings.Fields(cmd)
|
|
if len(fields) == 0 {
|
|
return ""
|
|
}
|
|
return displayPath(fields[0], root, home)
|
|
}
|
|
|
|
// ioDiscard avoids importing io in every call site for skill.Options.Stderr.
|
|
func ioDiscard() *discardWriter { return &discardWriter{} }
|
|
|
|
type discardWriter struct{}
|
|
|
|
func (d *discardWriter) Write(p []byte) (int, error) { return len(p), nil }
|
|
|
|
// DefaultLiveTimeout is used when --live is set without --timeout.
|
|
const DefaultLiveTimeout = 5 * time.Second
|
|
|
|
// MinLiveTimeout / MaxLiveTimeout bound --timeout.
|
|
const (
|
|
MinLiveTimeout = 1 * time.Second
|
|
MaxLiveTimeout = 60 * time.Second
|
|
)
|