返回 CodeWhale
doctor.rs
根目录 / crates / tui / src / doctor.rs
1 //! Structural, offline-by-default diagnostics shared by doctor renderers.
2
3 use std::path::{Path, PathBuf};
4
5 use anyhow::{Context, Result};
6 use serde::Serialize;
7
8 /// Canonical user-scoped paths reported by both human and JSON doctor output.
9 ///
10 /// Resolution is read-only: this type does not construct managers, create
11 /// directories, or trigger legacy migration.
12 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13 pub(crate) struct DoctorPathReport {
14 pub(crate) home: PathBuf,
15 pub(crate) config: PathBuf,
16 pub(crate) settings: PathBuf,
17 pub(crate) sessions: PathBuf,
18 pub(crate) logs: PathBuf,
19 pub(crate) automations: PathBuf,
20 pub(crate) task_manager_root: PathBuf,
21 pub(crate) task_manager_tasks: PathBuf,
22 pub(crate) task_manager_artifacts: PathBuf,
23 pub(crate) runtime_store: PathBuf,
24 pub(crate) runtime_events: PathBuf,
25 pub(crate) personal_fleet_definitions: PathBuf,
26 pub(crate) personal_fleet_agents: PathBuf,
27 pub(crate) secrets: PathBuf,
28 }
29
30 impl DoctorPathReport {
31 pub(crate) fn resolve(config_override: Option<&Path>) -> Result<Self> {
32 let home = codewhale_paths::codewhale_home()
33 .map_err(anyhow::Error::new)?
34 .context("could not resolve the canonical Codewhale state root")?;
35 let config = match config_override {
36 Some(path) => codewhale_config::resolve_config_path(Some(path.to_path_buf()))
37 .context("could not normalize the explicit config path")?,
38 None => codewhale_config::resolve_config_path(None)
39 .unwrap_or_else(|_| home.join(codewhale_config::CONFIG_FILE_NAME)),
40 };
41 let settings = crate::settings::Settings::path()
42 .context("could not resolve the canonical settings path")?;
43 let sessions = codewhale_config::resolve_state_dir("sessions")
44 .context("could not resolve the sessions path")?;
45 let logs = crate::runtime_log::log_directory()
46 .context("could not resolve the runtime log directory")?;
47 let automations = crate::automation_manager::default_automations_dir();
48 let task_manager_root = crate::task_manager::default_tasks_dir();
49 let task_manager_tasks = task_manager_root.join("tasks");
50 let task_manager_artifacts = task_manager_root.join("artifacts");
51 let runtime_config = crate::runtime_threads::RuntimeThreadManagerConfig::from_task_data_dir(
52 task_manager_root.clone(),
53 );
54 let runtime_store = runtime_config.data_dir;
55 let runtime_events = runtime_store.join("events");
56 let personal_fleet_definitions = crate::fleet::exact::personal_fleet_definitions_dir()
57 .context("could not resolve the personal Fleet definitions directory")?;
58 let personal_fleet_agents = crate::fleet::profile::personal_agent_profile_dir()
59 .context("could not resolve the personal Fleet agent directory")?;
60 let (secrets, _) = codewhale_secrets::FileKeyringStore::default_paths_read_only()
61 .context("could not resolve the file secret backend path")?;
62 Ok(Self {
63 home,
64 config,
65 settings,
66 sessions,
67 logs,
68 automations,
69 task_manager_root,
70 task_manager_tasks,
71 task_manager_artifacts,
72 runtime_store,
73 runtime_events,
74 personal_fleet_definitions,
75 personal_fleet_agents,
76 secrets,
77 })
78 }
79
80 pub(crate) fn entries(&self) -> [(&'static str, &Path); 14] {
81 [
82 ("home", self.home.as_path()),
83 ("config", self.config.as_path()),
84 ("settings", self.settings.as_path()),
85 ("sessions", self.sessions.as_path()),
86 ("logs", self.logs.as_path()),
87 ("automations", self.automations.as_path()),
88 ("task_manager_root", self.task_manager_root.as_path()),
89 ("task_manager_tasks", self.task_manager_tasks.as_path()),
90 (
91 "task_manager_artifacts",
92 self.task_manager_artifacts.as_path(),
93 ),
94 ("runtime_store", self.runtime_store.as_path()),
95 ("runtime_events", self.runtime_events.as_path()),
96 (
97 "personal_fleet_definitions",
98 self.personal_fleet_definitions.as_path(),
99 ),
100 (
101 "personal_fleet_agents",
102 self.personal_fleet_agents.as_path(),
103 ),
104 ("secrets", self.secrets.as_path()),
105 ]
106 }
107 }
108
109 /// Render structural secret-backend facts for the human doctor report.
110 ///
111 /// The input type cannot carry secret values, keeping this renderer safe by
112 /// construction.
113 pub(crate) fn secret_backend_human_lines(
114 diagnostic: &codewhale_secrets::SecretBackendDiagnostic,
115 ) -> Vec<String> {
116 use codewhale_secrets::{
117 SecretBackendDiagnosticKind, SecretBackendInspection, SecretBackendPresence,
118 };
119
120 let presence = |value| match value {
121 SecretBackendPresence::Present => "present",
122 SecretBackendPresence::Absent => "absent",
123 SecretBackendPresence::Unknown => "unknown",
124 };
125 let inspection = match diagnostic.inspection {
126 SecretBackendInspection::MetadataOnly => "metadata_only",
127 SecretBackendInspection::NotProbed => "not_probed",
128 };
129 let mut lines = match diagnostic.backend {
130 SecretBackendDiagnosticKind::File => vec![
131 "backend: file".to_string(),
132 format!("presence: {} ({inspection})", presence(diagnostic.presence)),
133 ],
134 SecretBackendDiagnosticKind::System => vec![
135 "backend: system".to_string(),
136 "status: unknown (not_probed)".to_string(),
137 ],
138 SecretBackendDiagnosticKind::Unknown => vec![
139 "backend: unknown".to_string(),
140 "status: unknown (not_probed; unsupported configuration)".to_string(),
141 ],
142 };
143 if let Some(path) = diagnostic.path.as_deref() {
144 lines.push(format!("path: {}", path.display()));
145 }
146 if let Some(path) = diagnostic.legacy_path.as_deref() {
147 lines.push(format!(
148 "legacy_path: {} ({}, {inspection})",
149 path.display(),
150 presence(diagnostic.legacy_presence)
151 ));
152 }
153 lines.push("No credential-store values were read or printed by this check.".to_string());
154 lines
155 }
156
157 /// Report key names — never values — for config entries whose value is
158 /// shaped like a bearer credential. `config.toml` is plain text, not a
159 /// secret store; doctor warns so tokens migrate to the secret backend
160 /// (morning-report issue: a plaintext OAuth token sat beside a `[redacted]`
161 /// sibling entry).
162 pub(crate) fn config_credential_shaped_keys(raw: &str) -> Vec<String> {
163 fn strong_shape(value: &str) -> bool {
164 const PREFIXES: [&str; 9] = [
165 "sk-",
166 "sk_",
167 "xai-",
168 "ghp_",
169 "gho_",
170 "github_pat_",
171 "xoxb-",
172 "xoxp-",
173 "eyJ",
174 ];
175 value.len() >= 20 && PREFIXES.iter().any(|prefix| value.starts_with(prefix))
176 }
177 fn suspect_key(key: &str) -> bool {
178 let key = key.to_ascii_lowercase();
179 [
180 "token",
181 "secret",
182 "password",
183 "credential",
184 "api_key",
185 "apikey",
186 "access_key",
187 ]
188 .iter()
189 .any(|needle| key.contains(needle))
190 }
191 fn random_shape(value: &str) -> bool {
192 value.len() >= 24
193 && value
194 .chars()
195 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
196 && value.chars().any(|ch| ch.is_ascii_digit())
197 && value.chars().any(|ch| ch.is_ascii_alphabetic())
198 }
199
200 let mut flagged: Vec<String> = Vec::new();
201 for line in raw.lines() {
202 let line = line.trim();
203 if line.starts_with('#') {
204 continue;
205 }
206 let Some((key, value)) = line.split_once('=') else {
207 continue;
208 };
209 let key = key.trim().trim_matches('"');
210 let value = value.trim();
211 let Some(value) = value
212 .strip_prefix('"')
213 .and_then(|value| value.strip_suffix('"'))
214 else {
215 continue;
216 };
217 if value.is_empty() || value.eq_ignore_ascii_case("[redacted]") {
218 continue;
219 }
220 if (strong_shape(value) || (suspect_key(key) && random_shape(value)))
221 && !flagged.iter().any(|existing| existing == key)
222 {
223 flagged.push(key.to_string());
224 }
225 }
226 flagged
227 }
228
229 /// Return only the non-secret network authority of a configured URL.
230 ///
231 /// Userinfo, path, query keys and values, and fragments are all omitted because
232 /// every one of those components can carry credentials. Parse failures also
233 /// omit the original input rather than echoing an attacker-controlled value.
234 pub(crate) fn structural_url_authority(url: &str) -> String {
235 let Some(parsed) = reqwest::Url::parse(url).ok() else {
236 return "unparseable (configured value omitted)".to_string();
237 };
238 let Some(host) = parsed.host_str() else {
239 return "unparseable (configured value omitted)".to_string();
240 };
241 let host = if host.contains(':') {
242 format!("[{host}]")
243 } else {
244 host.to_string()
245 };
246 let mut authority = format!("{}://{host}", parsed.scheme());
247 if let Some(port) = parsed.port() {
248 authority.push(':');
249 authority.push_str(&port.to_string());
250 }
251 authority
252 }
253
254 /// Explicit live operations requested for one doctor invocation.
255 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
256 pub(crate) struct DoctorProbeRequest {
257 pub(crate) check_updates: bool,
258 pub(crate) probe_api: bool,
259 pub(crate) probe_local: bool,
260 pub(crate) probe_mcp: bool,
261 }
262
263 impl DoctorProbeRequest {
264 /// Whether a release service may be contacted.
265 pub(crate) fn should_check_updates(self) -> bool {
266 self.check_updates
267 }
268
269 /// Whether the configured provider endpoint may be contacted.
270 ///
271 /// Hosted and local endpoints have separate opt-ins because a local probe
272 /// can wake a desktop-managed daemon.
273 pub(crate) fn should_probe_api(self, endpoint_is_local: bool) -> bool {
274 if endpoint_is_local {
275 self.probe_local
276 } else {
277 self.probe_api
278 }
279 }
280
281 /// Whether configured MCP processes may be started and contacted.
282 pub(crate) fn should_probe_mcp(self) -> bool {
283 self.probe_mcp
284 }
285 }
286
287 #[derive(Debug, Clone, PartialEq, Eq)]
288 enum DoctorUpdateReport {
289 NotChecked,
290 UpdateAvailable { latest: String },
291 UpToDate { latest: String },
292 CurrentNewer { latest: String },
293 ReleaseMetadataInvalid,
294 ReleaseCheckFailed,
295 }
296
297 fn doctor_update_report<E>(
298 current_version: &str,
299 latest_result: Result<String, E>,
300 ) -> DoctorUpdateReport {
301 let Ok(raw_latest) = latest_result else {
302 return DoctorUpdateReport::ReleaseCheckFailed;
303 };
304 let Some(latest) = doctor_safe_release_tag(&raw_latest) else {
305 return DoctorUpdateReport::ReleaseMetadataInvalid;
306 };
307 match codewhale_release::compare_release_versions(current_version, &latest) {
308 Ok(std::cmp::Ordering::Less) => DoctorUpdateReport::UpdateAvailable { latest },
309 Ok(std::cmp::Ordering::Equal) => DoctorUpdateReport::UpToDate { latest },
310 Ok(std::cmp::Ordering::Greater) => DoctorUpdateReport::CurrentNewer { latest },
311 Err(_) => DoctorUpdateReport::ReleaseMetadataInvalid,
312 }
313 }
314
315 /// Canonicalize a release tag before any doctor renderer sees it. A release
316 /// server response is untrusted input: it may not become an error echo or a
317 /// terminal control sequence merely because the user opted into an update
318 /// check.
319 fn doctor_safe_release_tag(raw: &str) -> Option<String> {
320 let version = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
321 semver::Version::parse(version)
322 .ok()
323 .map(|version| format!("v{version}"))
324 }
325
326 fn doctor_update_report_lines(report: &DoctorUpdateReport) -> Vec<String> {
327 match report {
328 DoctorUpdateReport::NotChecked => vec![
329 "latest: unknown (not checked; offline default)".to_string(),
330 "Run `codewhale doctor --check-updates` to opt in.".to_string(),
331 ],
332 DoctorUpdateReport::UpdateAvailable { latest } => vec![
333 format!("latest: {latest}"),
334 "Update available. Run `codewhale update` to install.".to_string(),
335 ],
336 DoctorUpdateReport::UpToDate { latest } => {
337 vec![
338 format!("latest: {latest}"),
339 "Already up to date.".to_string(),
340 ]
341 }
342 DoctorUpdateReport::CurrentNewer { latest } => vec![
343 format!("latest: {latest}"),
344 "Current build is newer than the latest published release.".to_string(),
345 ],
346 DoctorUpdateReport::ReleaseMetadataInvalid => vec![
347 "latest: unknown (release metadata invalid; details omitted)".to_string(),
348 "Run `codewhale update --check` to retry.".to_string(),
349 ],
350 DoctorUpdateReport::ReleaseCheckFailed => vec![
351 "latest: unknown (release check failed; details omitted)".to_string(),
352 "Run `codewhale update --check` to retry.".to_string(),
353 ],
354 }
355 }
356
357 /// Print the update portion of the human doctor report.
358 ///
359 /// The release service is contacted only when `--check-updates` populated the
360 /// explicit request bit. The default branch returns before constructing an
361 /// HTTP request. Failure details are deliberately typed and generic because
362 /// transport errors and release metadata are untrusted strings.
363 pub(crate) async fn print_update_report(probes: DoctorProbeRequest) {
364 let current_version = env!("CARGO_PKG_VERSION");
365 println!(" · current: v{current_version}");
366 let report = if probes.should_check_updates() {
367 doctor_update_report(
368 current_version,
369 codewhale_release::latest_release_tag_async(codewhale_release::ReleaseChannel::Stable)
370 .await,
371 )
372 } else {
373 DoctorUpdateReport::NotChecked
374 };
375 for (index, line) in doctor_update_report_lines(&report).into_iter().enumerate() {
376 let indent = if index == 0 { " ·" } else { " " };
377 println!("{indent} {line}");
378 }
379 }
380
381 #[cfg(test)]
382 #[path = "doctor/tests.rs"]
383 mod tests;
384
384 lines RUST