返回 DeepSeek-Reasonix
report.go
根目录 / internal / doctor / report.go
1 // Package doctor collects local, redacted diagnostics for issue reports.
2 package doctor
3
4 import (
5 "fmt"
6 "io"
7 "net/url"
8 "os"
9 "os/user"
10 "path/filepath"
11 "runtime"
12 "strings"
13
14 "github.com/BurntSushi/toml"
15
16 "reasonix/internal/agent"
17 "reasonix/internal/config"
18 fileencoding "reasonix/internal/fileutil/encoding"
19 "reasonix/internal/netclient"
20 "reasonix/internal/sandbox"
21 "reasonix/internal/skill"
22 "reasonix/internal/store"
23 )
24
25 type Options struct {
26 Version string
27 Config *config.Config
28 }
29
30 type Report struct {
31 Version string `json:"version"`
32 OS string `json:"os"`
33 Arch string `json:"arch"`
34 CWD string `json:"cwd,omitempty"`
35 Config ConfigReport `json:"config"`
36 Providers []ProviderReport `json:"providers"`
37 Plugins []PluginReport `json:"plugins,omitempty"`
38 LSP LSPReport `json:"lsp"`
39 Sessions SessionsReport `json:"sessions"`
40 Sandbox SandboxReport `json:"sandbox"`
41 Network NetworkReport `json:"network"`
42 Permission PermissionReport `json:"permission"`
43 Warnings []string `json:"warnings,omitempty"`
44 }
45
46 type ConfigReport struct {
47 SourcePath string `json:"source_path,omitempty"`
48 UserPath string `json:"user_path,omitempty"`
49 DefaultModel string `json:"default_model"`
50 }
51
52 type ProviderReport struct {
53 Name string `json:"name"`
54 Kind string `json:"kind"`
55 BaseURLHost string `json:"base_url_host,omitempty"`
56 Model string `json:"model,omitempty"`
57 Models []string `json:"models,omitempty"`
58 APIKeyEnv string `json:"api_key_env,omitempty"`
59 KeyPresent bool `json:"key_present"`
60 IsDefault bool `json:"is_default"`
61 ContextWindow int `json:"context_window,omitempty"`
62 }
63
64 type PluginReport struct {
65 Name string `json:"name"`
66 Transport string `json:"transport"`
67 AutoStart bool `json:"auto_start"`
68 Target string `json:"target,omitempty"`
69 }
70
71 type LSPReport struct {
72 Enabled bool `json:"enabled"`
73 Servers int `json:"servers"`
74 }
75
76 type SessionsReport struct {
77 Dir string `json:"dir,omitempty"`
78 Count int `json:"count"`
79 Bytes int64 `json:"bytes"`
80 Error string `json:"error,omitempty"`
81 }
82
83 type SandboxReport struct {
84 Bash string `json:"bash"`
85 Network bool `json:"network"`
86 WriteRoots []string `json:"write_roots,omitempty"`
87 // Available is whether an OS sandbox actually backs an "enforce" request on
88 // this host (Seatbelt or bubblewrap). Without it
89 // "enforce" refuses bash execution instead of running unconfined.
90 Available bool `json:"available"`
91 // Shell is the interpreter the bash tool resolved (kind and path).
92 Shell string `json:"shell,omitempty"`
93 // BashConfigIgnored is set when the config file requests bash = "enforce"
94 // but the platform force-resolves it to "off" (Windows, where the native
95 // backend is unsupported) — the one case where Bash silently disagrees with
96 // what the user wrote.
97 BashConfigIgnored bool `json:"bash_config_ignored,omitempty"`
98 }
99
100 type NetworkReport struct {
101 ProxyMode string `json:"proxy_mode"`
102 Proxy string `json:"proxy"`
103 NoProxy bool `json:"no_proxy"`
104 }
105
106 type PermissionReport struct {
107 Mode string `json:"mode"`
108 AllowRules int `json:"allow_rules"`
109 AskRules int `json:"ask_rules"`
110 DenyRules int `json:"deny_rules"`
111 }
112
113 func Collect(opts Options) Report {
114 cfg := opts.Config
115 var warnings []string
116 if cfg == nil {
117 var err error
118 cfg, err = config.Load()
119 if err != nil {
120 warnings = append(warnings, err.Error())
121 cfg = config.Default()
122 }
123 }
124 cwd, _ := os.Getwd()
125 sourcePath := config.SourcePath()
126 // Settings UIs and `reasonix config` edit the user-level config, but a
127 // project reasonix.toml outranks it. Users who toggle the sandbox off in
128 // Settings while the project file pins [sandbox] read the no-op as "bash is
129 // broken" (#5961, #6046) — surface the layering explicitly.
130 if sourcePath != "" && filepath.Base(sourcePath) == "reasonix.toml" {
131 if raw, err := fileencoding.ReadFileUTF8(sourcePath); err == nil && tomlHasSandboxTable(raw) {
132 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")
133 }
134 }
135 userPath := config.UserConfigPath()
136 if legacyPath := config.LegacyUserConfigPath(); userPath != "" && legacyPath != "" {
137 if _, userErr := os.Stat(userPath); userErr == nil {
138 if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
139 warnings = append(warnings, "legacy user config exists at "+redactHome(legacyPath)+
140 " but is ignored because "+redactHome(userPath)+" exists")
141 }
142 }
143 }
144 // A config that says enforce while the platform force-resolves it to off is
145 // the one case where bash behavior silently disagrees with the file the user
146 // edited (Windows has no OS-level Bash backend) — say it
147 // out loud instead of leaving it to be discovered from unconfined commands.
148 bashConfigIgnored := strings.TrimSpace(cfg.Sandbox.Bash) == "enforce" && cfg.BashMode() == "off"
149 if bashConfigIgnored {
150 warnings = append(warnings, `config requests [sandbox] bash = "enforce", but Windows does not provide an OS-level Bash sandbox; the setting is fixed to "off" and bash runs unconfined`)
151 }
152 // Supervised deployments sometimes override HOME onto a service config dir
153 // while Reasonix isolation should use REASONIX_HOME. Do not rewrite
154 // subprocess HOME automatically (#7600 rejected); surface the mismatch.
155 if warn := homeIsolationWarning(); warn != "" {
156 warnings = append(warnings, warn)
157 }
158 report := Report{
159 Version: opts.Version,
160 OS: runtime.GOOS,
161 Arch: runtime.GOARCH,
162 CWD: redactHome(cwd),
163 Config: ConfigReport{
164 SourcePath: redactHome(sourcePath),
165 UserPath: redactHome(userPath),
166 DefaultModel: cfg.DefaultModel,
167 },
168 LSP: LSPReport{
169 Enabled: cfg.LSP.Enabled,
170 Servers: len(cfg.LSP.Servers),
171 },
172 Sessions: collectSessions(config.SessionDir()),
173 Sandbox: SandboxReport{
174 Bash: cfg.BashMode(),
175 Network: cfg.Sandbox.Network,
176 WriteRoots: redactHomeAll(cfg.WriteRoots()),
177 Available: sandbox.Available(),
178 Shell: resolvedShellSummary(cfg),
179 BashConfigIgnored: bashConfigIgnored,
180 },
181 Network: NetworkReport{
182 ProxyMode: cfg.NetworkProxyMode(),
183 Proxy: netclient.Summary(cfg.NetworkProxySpec()),
184 NoProxy: strings.TrimSpace(cfg.Network.NoProxy) != "",
185 },
186 Permission: PermissionReport{
187 Mode: cfg.Permissions.Mode,
188 AllowRules: len(cfg.Permissions.Allow),
189 AskRules: len(cfg.Permissions.Ask),
190 DenyRules: len(cfg.Permissions.Deny),
191 },
192 Warnings: warnings,
193 }
194 // Skill / MCP capability health (optional diagnostics; never fail doctor).
195 if skStore := skill.New(skill.Options{ProjectRoot: cwd}); skStore != nil {
196 report.Warnings = append(report.Warnings, CollectSkillHealthWarnings(SkillHealthOptions{
197 Skills: skStore.List(),
198 Plugins: cfg.Plugins,
199 })...)
200 }
201 report.Sessions.Dir = redactHome(report.Sessions.Dir)
202 for i := range cfg.Providers {
203 p := cfg.Providers[i]
204 models := p.ModelList()
205 report.Providers = append(report.Providers, ProviderReport{
206 Name: p.Name,
207 Kind: p.Kind,
208 BaseURLHost: hostOnly(p.BaseURL),
209 Model: p.Model,
210 Models: models,
211 APIKeyEnv: p.APIKeyEnv,
212 KeyPresent: p.Configured(),
213 IsDefault: p.Name == cfg.DefaultModel,
214 ContextWindow: p.ContextWindow,
215 })
216 }
217 for _, p := range cfg.Plugins {
218 transport := p.Type
219 if transport == "" {
220 transport = "stdio"
221 }
222 report.Plugins = append(report.Plugins, PluginReport{
223 Name: p.Name,
224 Transport: transport,
225 AutoStart: p.ShouldAutoStart(),
226 Target: pluginTarget(p),
227 })
228 }
229 return report
230 }
231
232 func RenderText(r Report) string {
233 var b strings.Builder
234 fmt.Fprintf(&b, "reasonix %s doctor\n", r.Version)
235 fmt.Fprintf(&b, " system %s/%s\n", r.OS, r.Arch)
236 if r.CWD != "" {
237 fmt.Fprintf(&b, " cwd %s\n", r.CWD)
238 }
239 fmt.Fprintf(&b, " config %s\n", valueOr(r.Config.SourcePath, "not found - using defaults"))
240 fmt.Fprintf(&b, " user config %s\n", valueOr(r.Config.UserPath, "unavailable"))
241 fmt.Fprintf(&b, " model %s\n", valueOr(r.Config.DefaultModel, "(none)"))
242
243 // Warnings (e.g. a config that failed to parse and fell back to defaults) go
244 // up top, not buried under the full report where they read as "all fine".
245 for _, w := range r.Warnings {
246 fmt.Fprintf(&b, " warning: %s\n", w)
247 }
248
249 fmt.Fprintf(&b, "\nproviders\n")
250 for _, p := range r.Providers {
251 key := "missing"
252 if p.KeyPresent {
253 key = "present"
254 }
255 marker := ""
256 if p.IsDefault {
257 marker = " default"
258 }
259 fmt.Fprintf(&b, " %-16s %-8s %-24s key:%s%s\n", p.Name, p.Kind, valueOr(p.BaseURLHost, "(no host)"), key, marker)
260 }
261
262 fmt.Fprintf(&b, "\nplugins\n")
263 if len(r.Plugins) == 0 {
264 fmt.Fprintf(&b, " none configured\n")
265 } else {
266 for _, p := range r.Plugins {
267 fmt.Fprintf(&b, " %-16s %-8s %s\n", p.Name, p.Transport, valueOr(p.Target, "(redacted)"))
268 }
269 }
270
271 fmt.Fprintf(&b, "\nlsp\n")
272 fmt.Fprintf(&b, " enabled %v\n", r.LSP.Enabled)
273 fmt.Fprintf(&b, " servers %d configured overrides\n", r.LSP.Servers)
274
275 fmt.Fprintf(&b, "\nsessions\n")
276 fmt.Fprintf(&b, " dir %s\n", valueOr(r.Sessions.Dir, "unavailable"))
277 fmt.Fprintf(&b, " saved %d\n", r.Sessions.Count)
278 fmt.Fprintf(&b, " bytes %d\n", r.Sessions.Bytes)
279 if r.Sessions.Error != "" {
280 fmt.Fprintf(&b, " warning %s\n", r.Sessions.Error)
281 }
282
283 fmt.Fprintf(&b, "\nsandbox\n")
284 bashLine := r.Sandbox.Bash
285 if r.Sandbox.Bash == "enforce" && !r.Sandbox.Available {
286 bashLine += " (unavailable: no OS sandbox on this host; bash execution is refused. " + sandbox.UnavailableRemediation() + ")"
287 }
288 if r.Sandbox.BashConfigIgnored {
289 bashLine += ` (config requests "enforce", ignored: Windows has no OS-level Bash sandbox and fixes this setting to "off")`
290 }
291 fmt.Fprintf(&b, " bash %s\n", bashLine)
292 if r.Sandbox.Shell != "" {
293 fmt.Fprintf(&b, " shell %s\n", r.Sandbox.Shell)
294 }
295 fmt.Fprintf(&b, " network %v\n", r.Sandbox.Network)
296 fmt.Fprintf(&b, " write_roots %s\n", strings.Join(r.Sandbox.WriteRoots, ", "))
297
298 fmt.Fprintf(&b, "\nnetwork\n")
299 fmt.Fprintf(&b, " proxy_mode %s\n", r.Network.ProxyMode)
300 fmt.Fprintf(&b, " proxy %s\n", r.Network.Proxy)
301 fmt.Fprintf(&b, " no_proxy %v\n", r.Network.NoProxy)
302
303 fmt.Fprintf(&b, "\npermissions\n")
304 fmt.Fprintf(&b, " mode %s\n", valueOr(r.Permission.Mode, "ask"))
305 fmt.Fprintf(&b, " rules allow:%d ask:%d deny:%d\n", r.Permission.AllowRules, r.Permission.AskRules, r.Permission.DenyRules)
306 return b.String()
307 }
308
309 func collectSessions(dir string) SessionsReport {
310 r := SessionsReport{Dir: dir}
311 if dir == "" {
312 return r
313 }
314 sessions, err := agent.ListSessions(dir)
315 if err != nil {
316 r.Error = err.Error()
317 }
318 r.Count = len(sessions)
319 if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
320 if err != nil || d.IsDir() {
321 return nil
322 }
323 // Transcript storage spans the .jsonl checkpoint plus the event
324 // log/index; counting only checkpoints would under-report usage.
325 name := filepath.Base(path)
326 if !store.IsSessionTranscriptName(name) &&
327 !strings.HasSuffix(name, ".events.jsonl") &&
328 !strings.HasSuffix(name, ".event-index.json") {
329 return nil
330 }
331 if info, statErr := d.Info(); statErr == nil {
332 r.Bytes += info.Size()
333 }
334 return nil
335 }); err != nil && !os.IsNotExist(err) {
336 r.Error = err.Error()
337 }
338 return r
339 }
340
341 func pluginTarget(p config.PluginEntry) string {
342 if p.URL != "" {
343 return hostOnly(p.URL)
344 }
345 if p.Command == "" {
346 return ""
347 }
348 return filepath.Base(p.Command)
349 }
350
351 func hostOnly(raw string) string {
352 u, err := url.Parse(raw)
353 if err != nil || u.Hostname() == "" {
354 return ""
355 }
356 if port := u.Port(); port != "" {
357 return u.Hostname() + ":" + port
358 }
359 return u.Hostname()
360 }
361
362 func valueOr(s, fallback string) string {
363 if strings.TrimSpace(s) == "" {
364 return fallback
365 }
366 return s
367 }
368
369 // homeIsolationWarning detects a process HOME that differs from the OS account
370 // home while REASONIX_HOME is unset. Services should keep the real account HOME
371 // and isolate Reasonix state with REASONIX_HOME instead of rewriting HOME.
372 func homeIsolationWarning() string {
373 if strings.TrimSpace(os.Getenv("REASONIX_HOME")) != "" {
374 return ""
375 }
376 envHome := strings.TrimSpace(os.Getenv("HOME"))
377 if envHome == "" {
378 // Windows services often set USERPROFILE rather than HOME.
379 envHome = strings.TrimSpace(os.Getenv("USERPROFILE"))
380 }
381 if envHome == "" {
382 return ""
383 }
384 acct, err := user.Current()
385 if err != nil || acct == nil || strings.TrimSpace(acct.HomeDir) == "" {
386 return ""
387 }
388 envClean := filepath.Clean(envHome)
389 acctClean := filepath.Clean(acct.HomeDir)
390 if samePathFold(envClean, acctClean) {
391 return ""
392 }
393 // Do not embed either absolute path: when HOME is overridden, redactHome
394 // cannot mask the account home, and shareable doctor output must stay free
395 // of machine-local identity.
396 return "process HOME differs from the OS account home; keep the real account HOME for services and isolate Reasonix with REASONIX_HOME"
397 }
398
399 func samePathFold(a, b string) bool {
400 if a == b {
401 return true
402 }
403 if runtime.GOOS == "windows" {
404 return strings.EqualFold(a, b)
405 }
406 return false
407 }
408
409 // redactHome rewrites a path under the user's home directory to start with "~",
410 // so a shared diagnostics report doesn't carry the account name. Paths outside
411 // home are returned unchanged.
412 func redactHome(p string) string {
413 if p == "" {
414 return p
415 }
416 home, err := os.UserHomeDir()
417 if err != nil || home == "" {
418 return p
419 }
420 if p == home {
421 return "~"
422 }
423 if sep := string(os.PathSeparator); strings.HasPrefix(p, home+sep) {
424 return "~" + sep + p[len(home)+1:]
425 }
426 return p
427 }
428
429 func redactHomeAll(paths []string) []string {
430 out := make([]string, len(paths))
431 for i, p := range paths {
432 out[i] = redactHome(p)
433 }
434 return out
435 }
436
437 // resolvedShellSummary reports which interpreter the bash tool would run
438 // commands under, e.g. "bash (~/bin/bash)" or "powershell (C:\...\pwsh.exe)".
439 func resolvedShellSummary(cfg *config.Config) string {
440 sh := sandbox.ResolveShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, io.Discard)
441 if sh.Path == "" {
442 return sh.Kind.String() + " (not found)"
443 }
444 return sh.Kind.String() + " (" + redactHome(sh.Path) + ")"
445 }
446
447 // tomlHasSandboxTable reports whether raw TOML sets any [sandbox] key. A parse
448 // failure returns false — the config loader reports broken TOML on its own.
449 func tomlHasSandboxTable(raw []byte) bool {
450 var doc map[string]toml.Primitive
451 if _, err := toml.Decode(string(raw), &doc); err != nil {
452 return false
453 }
454 _, ok := doc["sandbox"]
455 return ok
456 }
457
457 lines GO