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