// Package doctor collects local, redacted diagnostics for issue reports. package doctor import ( "fmt" "io" "net/url" "os" "path/filepath" "runtime" "strings" "github.com/BurntSushi/toml" "reasonix/internal/agent" "reasonix/internal/config" fileencoding "reasonix/internal/fileutil/encoding" "reasonix/internal/netclient" "reasonix/internal/sandbox" "reasonix/internal/skill" "reasonix/internal/store" ) type Options struct { Version string Config *config.Config } type Report struct { Version string `json:"version"` OS string `json:"os"` Arch string `json:"arch"` CWD string `json:"cwd,omitempty"` Config ConfigReport `json:"config"` Providers []ProviderReport `json:"providers"` Plugins []PluginReport `json:"plugins,omitempty"` LSP LSPReport `json:"lsp"` Sessions SessionsReport `json:"sessions"` Sandbox SandboxReport `json:"sandbox"` Network NetworkReport `json:"network"` Permission PermissionReport `json:"permission"` Warnings []string `json:"warnings,omitempty"` } type ConfigReport struct { SourcePath string `json:"source_path,omitempty"` UserPath string `json:"user_path,omitempty"` DefaultModel string `json:"default_model"` } type ProviderReport struct { Name string `json:"name"` Kind string `json:"kind"` BaseURLHost string `json:"base_url_host,omitempty"` Model string `json:"model,omitempty"` Models []string `json:"models,omitempty"` APIKeyEnv string `json:"api_key_env,omitempty"` KeyPresent bool `json:"key_present"` IsDefault bool `json:"is_default"` ContextWindow int `json:"context_window,omitempty"` } type PluginReport struct { Name string `json:"name"` Transport string `json:"transport"` AutoStart bool `json:"auto_start"` Target string `json:"target,omitempty"` } type LSPReport struct { Enabled bool `json:"enabled"` Servers int `json:"servers"` } type SessionsReport struct { Dir string `json:"dir,omitempty"` Count int `json:"count"` Bytes int64 `json:"bytes"` Error string `json:"error,omitempty"` } type SandboxReport struct { Bash string `json:"bash"` Network bool `json:"network"` WriteRoots []string `json:"write_roots,omitempty"` // Available is whether an OS sandbox actually backs an "enforce" request on // this host (Seatbelt, bubblewrap, or the Windows helper). Without it // "enforce" refuses bash execution instead of running unconfined. Available bool `json:"available"` // Shell is the interpreter the bash tool resolved (kind and path). On // Windows this is the first thing to check when sandboxed commands fail: // Git-for-Windows/MSYS2 bash is far more fragile under a low-integrity // token than PowerShell. Shell string `json:"shell,omitempty"` // BashConfigIgnored is set when the config file requests bash = "enforce" // but the platform force-resolves it to "off" (Windows, until the native // backend is reliable) — the one case where Bash silently disagrees with // what the user wrote. BashConfigIgnored bool `json:"bash_config_ignored,omitempty"` } type NetworkReport struct { ProxyMode string `json:"proxy_mode"` Proxy string `json:"proxy"` NoProxy bool `json:"no_proxy"` } type PermissionReport struct { Mode string `json:"mode"` AllowRules int `json:"allow_rules"` AskRules int `json:"ask_rules"` DenyRules int `json:"deny_rules"` } func Collect(opts Options) Report { cfg := opts.Config var warnings []string if cfg == nil { var err error cfg, err = config.Load() if err != nil { warnings = append(warnings, err.Error()) cfg = config.Default() } } cwd, _ := os.Getwd() sourcePath := config.SourcePath() // Settings UIs and `reasonix config` edit the user-level config, but a // project reasonix.toml outranks it. Users who toggle the sandbox off in // Settings while the project file pins [sandbox] read the no-op as "bash is // broken" (#5961, #6046) — surface the layering explicitly. if sourcePath != "" && filepath.Base(sourcePath) == "reasonix.toml" { if raw, err := fileencoding.ReadFileUTF8(sourcePath); err == nil && tomlHasSandboxTable(raw) { warnings = append(warnings, "project "+redactHome(sourcePath)+" sets [sandbox]; it overrides user-level Settings -> Sandbox for this workspace — edit the project file to change sandbox behavior here") } } userPath := config.UserConfigPath() if legacyPath := config.LegacyUserConfigPath(); userPath != "" && legacyPath != "" { if _, userErr := os.Stat(userPath); userErr == nil { if _, legacyErr := os.Stat(legacyPath); legacyErr == nil { warnings = append(warnings, "legacy user config exists at "+redactHome(legacyPath)+ " but is ignored because "+redactHome(userPath)+" exists") } } } // A config that says enforce while the platform force-resolves it to off is // the one case where bash behavior silently disagrees with the file the user // edited (Windows forces off until the native backend is reliable) — say it // out loud instead of leaving it to be discovered from unconfined commands. bashConfigIgnored := strings.TrimSpace(cfg.Sandbox.Bash) == "enforce" && cfg.BashMode() == "off" if bashConfigIgnored { warnings = append(warnings, `config requests [sandbox] bash = "enforce", but this platform currently forces the Bash sandbox off; bash runs unconfined`) } report := Report{ Version: opts.Version, OS: runtime.GOOS, Arch: runtime.GOARCH, CWD: redactHome(cwd), Config: ConfigReport{ SourcePath: redactHome(sourcePath), UserPath: redactHome(userPath), DefaultModel: cfg.DefaultModel, }, LSP: LSPReport{ Enabled: cfg.LSP.Enabled, Servers: len(cfg.LSP.Servers), }, Sessions: collectSessions(config.SessionDir()), Sandbox: SandboxReport{ Bash: cfg.BashMode(), Network: cfg.Sandbox.Network, WriteRoots: redactHomeAll(cfg.WriteRoots()), Available: sandbox.Available(), Shell: resolvedShellSummary(cfg), BashConfigIgnored: bashConfigIgnored, }, Network: NetworkReport{ ProxyMode: cfg.NetworkProxyMode(), Proxy: netclient.Summary(cfg.NetworkProxySpec()), NoProxy: strings.TrimSpace(cfg.Network.NoProxy) != "", }, Permission: PermissionReport{ Mode: cfg.Permissions.Mode, AllowRules: len(cfg.Permissions.Allow), AskRules: len(cfg.Permissions.Ask), DenyRules: len(cfg.Permissions.Deny), }, Warnings: warnings, } // Skill / MCP capability health (optional diagnostics; never fail doctor). if skStore := skill.New(skill.Options{ProjectRoot: cwd}); skStore != nil { report.Warnings = append(report.Warnings, CollectSkillHealthWarnings(SkillHealthOptions{ Skills: skStore.List(), Plugins: cfg.Plugins, })...) } report.Sessions.Dir = redactHome(report.Sessions.Dir) for i := range cfg.Providers { p := cfg.Providers[i] models := p.ModelList() report.Providers = append(report.Providers, ProviderReport{ Name: p.Name, Kind: p.Kind, BaseURLHost: hostOnly(p.BaseURL), Model: p.Model, Models: models, APIKeyEnv: p.APIKeyEnv, KeyPresent: p.Configured(), IsDefault: p.Name == cfg.DefaultModel, ContextWindow: p.ContextWindow, }) } for _, p := range cfg.Plugins { transport := p.Type if transport == "" { transport = "stdio" } report.Plugins = append(report.Plugins, PluginReport{ Name: p.Name, Transport: transport, AutoStart: p.ShouldAutoStart(), Target: pluginTarget(p), }) } return report } func RenderText(r Report) string { var b strings.Builder fmt.Fprintf(&b, "reasonix %s doctor\n", r.Version) fmt.Fprintf(&b, " system %s/%s\n", r.OS, r.Arch) if r.CWD != "" { fmt.Fprintf(&b, " cwd %s\n", r.CWD) } fmt.Fprintf(&b, " config %s\n", valueOr(r.Config.SourcePath, "not found - using defaults")) fmt.Fprintf(&b, " user config %s\n", valueOr(r.Config.UserPath, "unavailable")) fmt.Fprintf(&b, " model %s\n", valueOr(r.Config.DefaultModel, "(none)")) // Warnings (e.g. a config that failed to parse and fell back to defaults) go // up top, not buried under the full report where they read as "all fine". for _, w := range r.Warnings { fmt.Fprintf(&b, " warning: %s\n", w) } fmt.Fprintf(&b, "\nproviders\n") for _, p := range r.Providers { key := "missing" if p.KeyPresent { key = "present" } marker := "" if p.IsDefault { marker = " default" } fmt.Fprintf(&b, " %-16s %-8s %-24s key:%s%s\n", p.Name, p.Kind, valueOr(p.BaseURLHost, "(no host)"), key, marker) } fmt.Fprintf(&b, "\nplugins\n") if len(r.Plugins) == 0 { fmt.Fprintf(&b, " none configured\n") } else { for _, p := range r.Plugins { fmt.Fprintf(&b, " %-16s %-8s %s\n", p.Name, p.Transport, valueOr(p.Target, "(redacted)")) } } fmt.Fprintf(&b, "\nlsp\n") fmt.Fprintf(&b, " enabled %v\n", r.LSP.Enabled) fmt.Fprintf(&b, " servers %d configured overrides\n", r.LSP.Servers) fmt.Fprintf(&b, "\nsessions\n") fmt.Fprintf(&b, " dir %s\n", valueOr(r.Sessions.Dir, "unavailable")) fmt.Fprintf(&b, " saved %d\n", r.Sessions.Count) fmt.Fprintf(&b, " bytes %d\n", r.Sessions.Bytes) if r.Sessions.Error != "" { fmt.Fprintf(&b, " warning %s\n", r.Sessions.Error) } fmt.Fprintf(&b, "\nsandbox\n") bashLine := r.Sandbox.Bash if r.Sandbox.Bash == "enforce" && !r.Sandbox.Available { bashLine += " (unavailable: no OS sandbox on this host; bash execution is refused. " + sandbox.UnavailableRemediation() + ")" } if r.Sandbox.BashConfigIgnored { bashLine += ` (config requests "enforce", ignored: this platform currently forces the Bash sandbox off)` } fmt.Fprintf(&b, " bash %s\n", bashLine) if r.Sandbox.Shell != "" { fmt.Fprintf(&b, " shell %s\n", r.Sandbox.Shell) } fmt.Fprintf(&b, " network %v\n", r.Sandbox.Network) fmt.Fprintf(&b, " write_roots %s\n", strings.Join(r.Sandbox.WriteRoots, ", ")) fmt.Fprintf(&b, "\nnetwork\n") fmt.Fprintf(&b, " proxy_mode %s\n", r.Network.ProxyMode) fmt.Fprintf(&b, " proxy %s\n", r.Network.Proxy) fmt.Fprintf(&b, " no_proxy %v\n", r.Network.NoProxy) fmt.Fprintf(&b, "\npermissions\n") fmt.Fprintf(&b, " mode %s\n", valueOr(r.Permission.Mode, "ask")) fmt.Fprintf(&b, " rules allow:%d ask:%d deny:%d\n", r.Permission.AllowRules, r.Permission.AskRules, r.Permission.DenyRules) return b.String() } func collectSessions(dir string) SessionsReport { r := SessionsReport{Dir: dir} if dir == "" { return r } sessions, err := agent.ListSessions(dir) if err != nil { r.Error = err.Error() } r.Count = len(sessions) if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } // Transcript storage spans the .jsonl checkpoint plus the event // log/index; counting only checkpoints would under-report usage. name := filepath.Base(path) if !store.IsSessionTranscriptName(name) && !strings.HasSuffix(name, ".events.jsonl") && !strings.HasSuffix(name, ".event-index.json") { return nil } if info, statErr := d.Info(); statErr == nil { r.Bytes += info.Size() } return nil }); err != nil && !os.IsNotExist(err) { r.Error = err.Error() } return r } func pluginTarget(p config.PluginEntry) string { if p.URL != "" { return hostOnly(p.URL) } if p.Command == "" { return "" } return filepath.Base(p.Command) } func hostOnly(raw string) string { u, err := url.Parse(raw) if err != nil || u.Hostname() == "" { return "" } if port := u.Port(); port != "" { return u.Hostname() + ":" + port } return u.Hostname() } func valueOr(s, fallback string) string { if strings.TrimSpace(s) == "" { return fallback } return s } // redactHome rewrites a path under the user's home directory to start with "~", // so a shared diagnostics report doesn't carry the account name. Paths outside // home are returned unchanged. func redactHome(p string) string { if p == "" { return p } home, err := os.UserHomeDir() if err != nil || home == "" { return p } if p == home { return "~" } if sep := string(os.PathSeparator); strings.HasPrefix(p, home+sep) { return "~" + sep + p[len(home)+1:] } return p } func redactHomeAll(paths []string) []string { out := make([]string, len(paths)) for i, p := range paths { out[i] = redactHome(p) } return out } // resolvedShellSummary reports which interpreter the bash tool would run // commands under, e.g. "bash (~/bin/bash)" or "powershell (C:\...\pwsh.exe)". func resolvedShellSummary(cfg *config.Config) string { sh := sandbox.ResolveShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, io.Discard) if sh.Path == "" { return sh.Kind.String() + " (not found)" } return sh.Kind.String() + " (" + redactHome(sh.Path) + ")" } // tomlHasSandboxTable reports whether raw TOML sets any [sandbox] key. A parse // failure returns false — the config loader reports broken TOML on its own. func tomlHasSandboxTable(raw []byte) bool { var doc map[string]toml.Primitive if _, err := toml.Decode(string(raw), &doc); err != nil { return false } _, ok := doc["sandbox"] return ok }