Files
2026-07-13 13:00:08 +08:00

413 lines
13 KiB
Go

// 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
}