返回 DeepSeek-Reasonix
diagnose.go
根目录 / internal / repair / diagnose.go
1 package repair
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/url"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "runtime"
13 "sort"
14 "strings"
15 "time"
16
17 "reasonix/internal/config"
18 "reasonix/internal/netclient"
19 )
20
21 type DiagnosticFinding struct {
22 Severity string `json:"severity"` // error | warning | info
23 Code string `json:"code"`
24 Scope string `json:"scope,omitempty"`
25 Message string `json:"message"`
26 Remediation string `json:"remediation,omitempty"`
27 }
28
29 type DiagnosticReport struct {
30 GeneratedAt string `json:"generatedAt"`
31 Root string `json:"root"`
32 Network bool `json:"network"`
33 Snapshots []DiagnosticSnapshot `json:"snapshots"`
34 PendingUpdate *DiagnosticUpdate `json:"pendingUpdate,omitempty"`
35 Findings []DiagnosticFinding `json:"findings"`
36 }
37
38 type DiagnosticSnapshot struct {
39 ID string `json:"id"`
40 RecordedAt string `json:"recordedAt"`
41 Version string `json:"version,omitempty"`
42 }
43
44 type DiagnosticUpdate struct {
45 FromVersion string `json:"fromVersion,omitempty"`
46 ToVersion string `json:"toVersion"`
47 }
48
49 type DiagnoseOptions struct {
50 Root string
51 Network bool
52 Timeout time.Duration
53 }
54
55 func Diagnose(ctx context.Context, opts DiagnoseOptions) (DiagnosticReport, error) {
56 root := strings.TrimSpace(opts.Root)
57 if root == "" {
58 root = "."
59 }
60 if abs, err := filepath.Abs(root); err == nil {
61 root = abs
62 }
63 report := DiagnosticReport{GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), Root: root, Network: opts.Network, Snapshots: []DiagnosticSnapshot{}, Findings: []DiagnosticFinding{}}
64 if snapshots, listErr := ListConfigSnapshots(); listErr == nil {
65 for _, snapshot := range snapshots {
66 report.Snapshots = append(report.Snapshots, DiagnosticSnapshot{ID: snapshot.ID, RecordedAt: snapshot.RecordedAt, Version: snapshot.Version})
67 }
68 }
69 if pending, pendingErr := ReadPendingUpdate(); pendingErr == nil {
70 report.PendingUpdate = &DiagnosticUpdate{FromVersion: pending.FromVersion, ToVersion: pending.ToVersion}
71 }
72 configReport, err := InspectAndRepairConfig(ConfigOptions{Root: root})
73 if err != nil {
74 return report, err
75 }
76 valid := true
77 for _, check := range configReport.Checks {
78 if check.Exists && !check.Valid {
79 valid = false
80 report.add("error", "config.invalid_toml", check.Scope, "Configuration cannot be parsed: "+check.Error, "Run reasonix doctor repair; add --project only for a project config.")
81 }
82 }
83 checkSensitiveFileMode(&report, config.UserConfigPath(), "global config")
84 checkSensitiveFileMode(&report, config.UserCredentialsPath(), "credential file")
85 checkDirectoryMode(&report, config.ReasonixHomeDir(), "Reasonix home")
86 if config.MemoryUserDir() != config.ReasonixHomeDir() {
87 checkDirectoryMode(&report, config.MemoryUserDir(), "Reasonix state directory")
88 }
89 checkDirectoryMode(&report, root, "project root")
90 checkDerivedJSON(&report)
91 if !valid {
92 return report, nil
93 }
94 // Diagnose is documented as read-only: LoadForRoot would rewrite legacy MCP
95 // `tier` lines on disk, so use the variant that never writes config files.
96 cfg, err := config.LoadForRootReadOnly(root)
97 if err != nil {
98 report.add("error", "config.load_failed", "runtime", err.Error(), "Run reasonix doctor repair, then inspect the reported global or project configuration.")
99 return report, nil
100 }
101 validateProviders(&report, cfg)
102 validatePlugins(&report, cfg, root)
103 validatePermissions(&report, cfg)
104 if err := netclient.Validate(cfg.NetworkProxySpec()); err != nil {
105 report.add("error", "network.invalid_proxy", "network", "Proxy configuration is invalid: "+err.Error(), "Correct [network] proxy settings or set proxy_mode = \"off\".")
106 }
107 if opts.Network {
108 timeout := opts.Timeout
109 if timeout <= 0 {
110 timeout = 8 * time.Second
111 }
112 probeProviderNetwork(ctx, &report, cfg, timeout)
113 }
114 return report, nil
115 }
116
117 func (r *DiagnosticReport) add(severity, code, scope, message, remediation string) {
118 r.Findings = append(r.Findings, DiagnosticFinding{Severity: severity, Code: code, Scope: scope, Message: message, Remediation: remediation})
119 }
120
121 func (r DiagnosticReport) HasErrors() bool {
122 for _, finding := range r.Findings {
123 if finding.Severity == "error" {
124 return true
125 }
126 }
127 return false
128 }
129
130 func validateProviders(report *DiagnosticReport, cfg *config.Config) {
131 seen := map[string]bool{}
132 for i := range cfg.Providers {
133 entry := &cfg.Providers[i]
134 scope := "provider:" + strings.TrimSpace(entry.Name)
135 if strings.TrimSpace(entry.Name) == "" {
136 report.add("error", "provider.missing_name", "provider", "A provider has no name.", "Set a stable, unique provider name.")
137 continue
138 }
139 if seen[entry.Name] {
140 report.add("error", "provider.duplicate_name", scope, "Provider name is declared more than once.", "Rename or remove the duplicate provider entry.")
141 }
142 seen[entry.Name] = true
143 if entry.Kind != "openai" && entry.Kind != "anthropic" {
144 report.add("error", "provider.unsupported_kind", scope, fmt.Sprintf("Provider kind %q is not registered by packaged Reasonix builds.", entry.Kind), "Use openai or anthropic compatibility.")
145 }
146 if len(entry.ModelList()) == 0 {
147 report.add("error", "provider.no_models", scope, "Provider has no configured model.", "Set model or models.")
148 }
149 if err := validateHTTPURL(entry.BaseURL); err != nil {
150 report.add("error", "provider.invalid_url", scope, "Provider base URL is invalid: "+err.Error(), "Use an absolute http:// or https:// URL.")
151 }
152 if entry.ModelsURL != "" {
153 if err := validateHTTPURL(entry.ModelsURL); err != nil {
154 report.add("error", "provider.invalid_models_url", scope, "Provider models URL is invalid: "+err.Error(), "Use an absolute http:// or https:// URL.")
155 }
156 }
157 if entry.APIKeyEnv != "" && !config.IsValidCredentialKey(entry.APIKeyEnv) {
158 report.add("error", "provider.invalid_key_name", scope, "api_key_env is not a valid environment variable name.", "Use letters, numbers, and underscores.")
159 } else if entry.RequiresAPIKey() && entry.APIKey() == "" {
160 report.add("warning", "provider.missing_key", scope, "The configured API key is missing from the global Reasonix credential file.", "Add the key in Settings or <Reasonix home>/.env.")
161 }
162 }
163 if strings.TrimSpace(cfg.DefaultModel) == "" {
164 report.add("warning", "model.no_default", "model", "No default model is configured.", "Select a default provider/model in Settings.")
165 } else if _, ok := cfg.ResolveModel(cfg.DefaultModel); !ok {
166 report.add("error", "model.invalid_default", "model", fmt.Sprintf("Default model %q does not resolve to a configured provider/model.", cfg.DefaultModel), "Choose an existing provider or provider/model reference.")
167 }
168 }
169
170 func validatePlugins(report *DiagnosticReport, cfg *config.Config, root string) {
171 seen := map[string]bool{}
172 for _, plugin := range cfg.Plugins {
173 scope := "plugin:" + strings.TrimSpace(plugin.Name)
174 if plugin.Name == "" {
175 report.add("error", "plugin.missing_name", "plugin", "An MCP server has no name.", "Set a stable MCP server name.")
176 continue
177 }
178 if seen[plugin.Name] {
179 report.add("error", "plugin.duplicate_name", scope, "MCP server name is declared more than once.", "Remove or rename the duplicate entry.")
180 }
181 seen[plugin.Name] = true
182 switch strings.ToLower(strings.TrimSpace(plugin.Type)) {
183 case "", "stdio":
184 command := strings.TrimSpace(plugin.Command)
185 if command == "" {
186 report.add("error", "plugin.missing_command", scope, "stdio MCP server has no command.", "Configure an executable command or disable the server.")
187 } else if !commandAvailable(command, root) {
188 report.add("warning", "plugin.command_missing", scope, fmt.Sprintf("MCP command %q was not found.", command), "Install the command, use an absolute path, or disable auto_start.")
189 }
190 case "http", "sse", "streamable-http":
191 if err := validateHTTPURL(plugin.URL); err != nil {
192 report.add("error", "plugin.invalid_url", scope, "Remote MCP URL is invalid: "+err.Error(), "Use an absolute http:// or https:// URL.")
193 }
194 default:
195 report.add("error", "plugin.invalid_type", scope, fmt.Sprintf("Unknown MCP transport %q.", plugin.Type), "Use stdio, http, or sse.")
196 }
197 }
198 }
199
200 func validatePermissions(report *DiagnosticReport, cfg *config.Config) {
201 lists := map[string][]string{"allow": cfg.Permissions.Allow, "ask": cfg.Permissions.Ask, "deny": cfg.Permissions.Deny}
202 owners := map[string][]string{}
203 for name, rules := range lists {
204 for _, rule := range rules {
205 rule = strings.TrimSpace(rule)
206 if rule != "" {
207 owners[rule] = append(owners[rule], name)
208 }
209 }
210 }
211 rules := make([]string, 0, len(owners))
212 for rule := range owners {
213 rules = append(rules, rule)
214 }
215 sort.Strings(rules)
216 for _, rule := range rules {
217 if len(owners[rule]) > 1 {
218 report.add("warning", "permissions.conflict", "permissions", fmt.Sprintf("Permission rule %q appears in %s; deny takes precedence.", rule, strings.Join(owners[rule], ", ")), "Keep each exact rule in one permission list.")
219 }
220 }
221 }
222
223 func validateHTTPURL(raw string) error {
224 u, err := url.Parse(strings.TrimSpace(raw))
225 if err != nil {
226 return err
227 }
228 if u.Scheme != "http" && u.Scheme != "https" {
229 return fmt.Errorf("scheme must be http or https")
230 }
231 if u.Hostname() == "" {
232 return fmt.Errorf("host is required")
233 }
234 return nil
235 }
236
237 func commandAvailable(command, root string) bool {
238 if strings.ContainsAny(command, `/\\`) || filepath.IsAbs(command) {
239 if !filepath.IsAbs(command) {
240 command = filepath.Join(root, command)
241 }
242 st, err := os.Stat(command)
243 if err != nil || st.IsDir() {
244 return false
245 }
246 return runtime.GOOS == "windows" || st.Mode().Perm()&0o111 != 0
247 }
248 _, err := exec.LookPath(command)
249 return err == nil
250 }
251
252 func checkSensitiveFileMode(report *DiagnosticReport, path, label string) {
253 if runtime.GOOS == "windows" || path == "" {
254 return
255 }
256 st, err := os.Stat(path)
257 if err != nil || st.IsDir() {
258 return
259 }
260 if st.Mode().Perm()&0o077 != 0 {
261 report.add("warning", "file.permissions", label, fmt.Sprintf("%s is readable by group or other users.", label), "Set file permissions to 0600.")
262 }
263 }
264
265 func checkDirectoryMode(report *DiagnosticReport, path, label string) {
266 if path == "" {
267 report.add("warning", "directory.unavailable", label, label+" path is unavailable.", "Restore the user home or Reasonix path environment configuration.")
268 return
269 }
270 st, err := os.Stat(path)
271 if err != nil {
272 if !os.IsNotExist(err) {
273 report.add("warning", "directory.unreadable", label, label+" cannot be inspected.", "Check directory ownership and access permissions.")
274 }
275 return
276 }
277 if !st.IsDir() {
278 report.add("error", "directory.not_directory", label, label+" is not a directory.", "Correct the configured path.")
279 return
280 }
281 if runtime.GOOS != "windows" && st.Mode().Perm()&0o200 == 0 {
282 report.add("warning", "directory.not_writable", label, label+" is not owner-writable.", "Grant the current user write access or choose another path.")
283 }
284 }
285
286 func checkDerivedJSON(report *DiagnosticReport) {
287 paths := derivedStatePaths()
288 for name, path := range paths {
289 b, err := os.ReadFile(path)
290 if err != nil {
291 continue
292 }
293 if !json.Valid(b) {
294 report.add("warning", "derived.invalid_json", "derived:"+name, fmt.Sprintf("Derived desktop state %s is malformed.", filepath.Base(path)), "Run reasonix doctor repair, or delete the broken derived file after backing it up.")
295 }
296 }
297 }
298
299 func probeProviderNetwork(ctx context.Context, report *DiagnosticReport, cfg *config.Config, timeout time.Duration) {
300 client, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{DialTimeout: timeout, TLSHandshakeTimeout: timeout, ResponseHeaderTimeout: timeout})
301 if err != nil {
302 report.add("error", "network.client_failed", "network", "Cannot build network client: "+err.Error(), "Correct proxy settings.")
303 return
304 }
305 client.Timeout = timeout
306 for i := range cfg.Providers {
307 entry := &cfg.Providers[i]
308 if validateHTTPURL(entry.BaseURL) != nil {
309 continue
310 }
311 urls, err := config.BuildModelFetchURLs(entry.BaseURL, entry.ModelsURL)
312 if err != nil || len(urls) == 0 {
313 continue
314 }
315 probeCtx, cancel := context.WithTimeout(ctx, timeout)
316 req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, urls[0], nil)
317 if err == nil {
318 for key, value := range entry.Headers {
319 req.Header.Set(key, value)
320 }
321 if key := entry.APIKey(); key != "" {
322 if entry.Kind == "anthropic" && !entry.AuthHeader {
323 req.Header.Set("x-api-key", key)
324 } else {
325 req.Header.Set("Authorization", "Bearer "+key)
326 }
327 }
328 resp, callErr := client.Do(req)
329 if callErr != nil {
330 report.add("warning", "network.unreachable", "provider:"+entry.Name, "Provider endpoint could not be reached: "+redactNetworkError(callErr), "Check DNS, proxy, firewall, and provider availability.")
331 } else {
332 _ = resp.Body.Close()
333 switch {
334 case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
335 report.add("error", "network.authentication_failed", "provider:"+entry.Name, fmt.Sprintf("Provider rejected credentials with HTTP %d.", resp.StatusCode), "Update the provider credential in Reasonix Settings.")
336 case resp.StatusCode >= 200 && resp.StatusCode < 300:
337 report.add("info", "network.ok", "provider:"+entry.Name, "Provider endpoint and credentials are reachable.", "")
338 default:
339 report.add("warning", "network.unexpected_status", "provider:"+entry.Name, fmt.Sprintf("Provider model endpoint returned HTTP %d.", resp.StatusCode), "Verify models_url or test the provider from Settings.")
340 }
341 }
342 }
343 cancel()
344 }
345 }
346
347 func redactNetworkError(err error) string {
348 if err == nil {
349 return ""
350 }
351 return "network request failed"
352 }
353
353 lines GO