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