返回 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 use std::time::Duration;
5
6 use anyhow::{Context, Result};
7 use serde::Serialize;
8
9 /// Canonical user-scoped paths reported by both human and JSON doctor output.
10 ///
11 /// Resolution is read-only: this type does not construct managers, create
12 /// directories, or trigger legacy migration.
13 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
14 pub(crate) struct DoctorPathReport {
15 pub(crate) home: PathBuf,
16 pub(crate) config: PathBuf,
17 pub(crate) settings: PathBuf,
18 pub(crate) sessions: PathBuf,
19 pub(crate) logs: PathBuf,
20 pub(crate) automations: PathBuf,
21 pub(crate) task_manager_root: PathBuf,
22 pub(crate) task_manager_tasks: PathBuf,
23 pub(crate) task_manager_artifacts: PathBuf,
24 pub(crate) runtime_store: PathBuf,
25 pub(crate) runtime_events: PathBuf,
26 pub(crate) personal_fleet_definitions: PathBuf,
27 pub(crate) personal_fleet_agents: PathBuf,
28 pub(crate) secrets: PathBuf,
29 }
30
31 impl DoctorPathReport {
32 pub(crate) fn resolve(config_override: Option<&Path>) -> Result<Self> {
33 let home = codewhale_paths::codewhale_home()
34 .map_err(anyhow::Error::new)?
35 .context("could not resolve the canonical Codewhale state root")?;
36 let config = match config_override {
37 Some(path) => codewhale_config::resolve_config_path(Some(path.to_path_buf()))
38 .context("could not normalize the explicit config path")?,
39 None => codewhale_config::resolve_config_path(None)
40 .unwrap_or_else(|_| home.join(codewhale_config::CONFIG_FILE_NAME)),
41 };
42 let settings = crate::settings::Settings::path()
43 .context("could not resolve the canonical settings path")?;
44 let sessions = codewhale_config::resolve_state_dir("sessions")
45 .context("could not resolve the sessions path")?;
46 let logs = crate::runtime_log::log_directory()
47 .context("could not resolve the runtime log directory")?;
48 let automations = crate::automation_manager::default_automations_dir();
49 let task_manager_root = crate::task_manager::default_tasks_dir();
50 let task_manager_tasks = task_manager_root.join("tasks");
51 let task_manager_artifacts = task_manager_root.join("artifacts");
52 let runtime_config = crate::runtime_threads::RuntimeThreadManagerConfig::from_task_data_dir(
53 task_manager_root.clone(),
54 );
55 let runtime_store = runtime_config.data_dir;
56 let runtime_events = runtime_store.join("events");
57 let personal_fleet_definitions = crate::fleet::exact::personal_fleet_definitions_dir()
58 .context("could not resolve the personal Fleet definitions directory")?;
59 let personal_fleet_agents = crate::fleet::profile::personal_agent_profile_dir()
60 .context("could not resolve the personal Fleet agent directory")?;
61 let (secrets, _) = codewhale_secrets::FileKeyringStore::default_paths_read_only()
62 .context("could not resolve the file secret backend path")?;
63 Ok(Self {
64 home,
65 config,
66 settings,
67 sessions,
68 logs,
69 automations,
70 task_manager_root,
71 task_manager_tasks,
72 task_manager_artifacts,
73 runtime_store,
74 runtime_events,
75 personal_fleet_definitions,
76 personal_fleet_agents,
77 secrets,
78 })
79 }
80
81 pub(crate) fn entries(&self) -> [(&'static str, &Path); 14] {
82 [
83 ("home", self.home.as_path()),
84 ("config", self.config.as_path()),
85 ("settings", self.settings.as_path()),
86 ("sessions", self.sessions.as_path()),
87 ("logs", self.logs.as_path()),
88 ("automations", self.automations.as_path()),
89 ("task_manager_root", self.task_manager_root.as_path()),
90 ("task_manager_tasks", self.task_manager_tasks.as_path()),
91 (
92 "task_manager_artifacts",
93 self.task_manager_artifacts.as_path(),
94 ),
95 ("runtime_store", self.runtime_store.as_path()),
96 ("runtime_events", self.runtime_events.as_path()),
97 (
98 "personal_fleet_definitions",
99 self.personal_fleet_definitions.as_path(),
100 ),
101 (
102 "personal_fleet_agents",
103 self.personal_fleet_agents.as_path(),
104 ),
105 ("secrets", self.secrets.as_path()),
106 ]
107 }
108 }
109
110 /// Render structural secret-backend facts for the human doctor report.
111 ///
112 /// The input type cannot carry secret values, keeping this renderer safe by
113 /// construction.
114 pub(crate) fn secret_backend_human_lines(
115 diagnostic: &codewhale_secrets::SecretBackendDiagnostic,
116 ) -> Vec<String> {
117 use codewhale_secrets::{
118 SecretBackendDiagnosticKind, SecretBackendInspection, SecretBackendPresence,
119 };
120
121 let presence = |value| match value {
122 SecretBackendPresence::Present => "present",
123 SecretBackendPresence::Absent => "absent",
124 SecretBackendPresence::Unknown => "unknown",
125 };
126 let inspection = match diagnostic.inspection {
127 SecretBackendInspection::MetadataOnly => "metadata_only",
128 SecretBackendInspection::NotProbed => "not_probed",
129 };
130 let mut lines = match diagnostic.backend {
131 SecretBackendDiagnosticKind::File => vec![
132 "backend: file".to_string(),
133 format!("presence: {} ({inspection})", presence(diagnostic.presence)),
134 ],
135 SecretBackendDiagnosticKind::System => vec![
136 "backend: system".to_string(),
137 "status: unknown (not_probed)".to_string(),
138 ],
139 SecretBackendDiagnosticKind::Unknown => vec![
140 "backend: unknown".to_string(),
141 "status: unknown (not_probed; unsupported configuration)".to_string(),
142 ],
143 };
144 if let Some(path) = diagnostic.path.as_deref() {
145 lines.push(format!("path: {}", path.display()));
146 }
147 if let Some(path) = diagnostic.legacy_path.as_deref() {
148 lines.push(format!(
149 "legacy_path: {} ({}, {inspection})",
150 path.display(),
151 presence(diagnostic.legacy_presence)
152 ));
153 }
154 lines.push("No credential-store values were read or printed by this check.".to_string());
155 lines
156 }
157
158 /// Report key names — never values — for config entries whose value is
159 /// shaped like a bearer credential. `config.toml` is plain text, not a
160 /// secret store; doctor warns so tokens migrate to the secret backend
161 /// (morning-report issue: a plaintext OAuth token sat beside a `[redacted]`
162 /// sibling entry).
163 pub(crate) fn config_credential_shaped_keys(raw: &str) -> Vec<String> {
164 fn strong_shape(value: &str) -> bool {
165 const PREFIXES: [&str; 9] = [
166 "sk-",
167 "sk_",
168 "xai-",
169 "ghp_",
170 "gho_",
171 "github_pat_",
172 "xoxb-",
173 "xoxp-",
174 "eyJ",
175 ];
176 value.len() >= 20 && PREFIXES.iter().any(|prefix| value.starts_with(prefix))
177 }
178 fn suspect_key(key: &str) -> bool {
179 let key = key.to_ascii_lowercase();
180 [
181 "token",
182 "secret",
183 "password",
184 "credential",
185 "api_key",
186 "apikey",
187 "access_key",
188 ]
189 .iter()
190 .any(|needle| key.contains(needle))
191 }
192 fn random_shape(value: &str) -> bool {
193 value.len() >= 24
194 && value
195 .chars()
196 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
197 && value.chars().any(|ch| ch.is_ascii_digit())
198 && value.chars().any(|ch| ch.is_ascii_alphabetic())
199 }
200
201 let mut flagged: Vec<String> = Vec::new();
202 for line in raw.lines() {
203 let line = line.trim();
204 if line.starts_with('#') {
205 continue;
206 }
207 let Some((key, value)) = line.split_once('=') else {
208 continue;
209 };
210 let key = key.trim().trim_matches('"');
211 let value = value.trim();
212 let Some(value) = value
213 .strip_prefix('"')
214 .and_then(|value| value.strip_suffix('"'))
215 else {
216 continue;
217 };
218 if value.is_empty() || value.eq_ignore_ascii_case("[redacted]") {
219 continue;
220 }
221 if (strong_shape(value) || (suspect_key(key) && random_shape(value)))
222 && !flagged.iter().any(|existing| existing == key)
223 {
224 flagged.push(key.to_string());
225 }
226 }
227 flagged
228 }
229
230 /// Return only the non-secret network authority of a configured URL.
231 ///
232 /// Userinfo, path, query keys and values, and fragments are all omitted because
233 /// every one of those components can carry credentials. Parse failures also
234 /// omit the original input rather than echoing an attacker-controlled value.
235 pub(crate) fn structural_url_authority(url: &str) -> String {
236 let Some(parsed) = reqwest::Url::parse(url).ok() else {
237 return "unparseable (configured value omitted)".to_string();
238 };
239 let Some(host) = parsed.host_str() else {
240 return "unparseable (configured value omitted)".to_string();
241 };
242 // Url::host_str already includes the brackets around an IPv6 address.
243 let mut authority = format!("{}://{host}", parsed.scheme());
244 if let Some(port) = parsed.port() {
245 authority.push(':');
246 authority.push_str(&port.to_string());
247 }
248 authority
249 }
250
251 /// Explicit live operations requested for one doctor invocation.
252 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
253 pub(crate) struct DoctorProbeRequest {
254 pub(crate) check_updates: bool,
255 pub(crate) probe_api: bool,
256 pub(crate) probe_local: bool,
257 pub(crate) probe_mcp: bool,
258 pub(crate) probe_search: bool,
259 }
260
261 impl DoctorProbeRequest {
262 /// Whether a release service may be contacted.
263 pub(crate) fn should_check_updates(self) -> bool {
264 self.check_updates
265 }
266
267 /// Whether the configured provider endpoint may be contacted.
268 ///
269 /// Hosted and local endpoints have separate opt-ins because a local probe
270 /// can wake a desktop-managed daemon.
271 pub(crate) fn should_probe_api(self, endpoint_is_local: bool) -> bool {
272 if endpoint_is_local {
273 self.probe_local
274 } else {
275 self.probe_api
276 }
277 }
278
279 /// Whether configured MCP processes may be started and contacted.
280 pub(crate) fn should_probe_mcp(self) -> bool {
281 self.probe_mcp
282 }
283
284 /// Whether the selected web-search provider authority may be contacted.
285 pub(crate) fn should_probe_search(self) -> bool {
286 self.probe_search
287 }
288 }
289
290 const SEARCH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
291
292 /// Bounded, credential-free reachability result for the configured search
293 /// provider. This intentionally cannot represent a successful search or valid
294 /// authentication: a HEAD response proves transport reachability only.
295 #[derive(Debug, Clone, PartialEq, Eq)]
296 pub(crate) enum DoctorSearchProbeReport {
297 NotChecked,
298 FeatureDisabled,
299 ConfigurationError(crate::tools::web_search::SearchProbeTargetError),
300 PolicyDenied {
301 authority: String,
302 },
303 PolicyApprovalRequired {
304 authority: String,
305 },
306 Reachable {
307 authority: String,
308 status: u16,
309 },
310 Unreachable {
311 authority: String,
312 failure: DoctorSearchProbeFailure,
313 },
314 }
315
316 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
317 pub(crate) enum DoctorSearchProbeFailure {
318 Timeout,
319 Connection,
320 Request,
321 }
322
323 /// Probe the selected search provider without reading credentials, issuing a
324 /// query, following redirects, or writing a network-policy audit receipt.
325 pub(crate) async fn doctor_search_probe(
326 config: &crate::config::Config,
327 probes: DoctorProbeRequest,
328 ) -> DoctorSearchProbeReport {
329 use crate::network_policy::{Decision, NetworkPolicyDecider};
330
331 if !probes.should_probe_search() {
332 return DoctorSearchProbeReport::NotChecked;
333 }
334 if !config
335 .features()
336 .enabled(crate::features::Feature::WebSearch)
337 {
338 return DoctorSearchProbeReport::FeatureDisabled;
339 }
340
341 let provider = config.search_provider();
342 let base_url = config
343 .search
344 .as_ref()
345 .and_then(|search| search.base_url.as_deref());
346 let target = match crate::tools::web_search::search_probe_target(provider, base_url) {
347 Ok(target) => target,
348 Err(error) => return DoctorSearchProbeReport::ConfigurationError(error),
349 };
350 let authority = structural_url_authority(target.url.as_str());
351
352 if let Some(policy) = config.network.clone().map(|network| {
353 // Doctor is a read-only diagnostic. Evaluate the same typed policy as
354 // web_search, but do not attach the runtime audit writer.
355 NetworkPolicyDecider::new(network.into_runtime(), None)
356 }) {
357 match policy.evaluate(&target.host, "web_search") {
358 Decision::Allow => {}
359 Decision::Deny => return DoctorSearchProbeReport::PolicyDenied { authority },
360 Decision::Prompt => {
361 return DoctorSearchProbeReport::PolicyApprovalRequired { authority };
362 }
363 }
364 }
365
366 let client = match crate::tls::reqwest_client_builder()
367 .redirect(reqwest::redirect::Policy::none())
368 .connect_timeout(SEARCH_PROBE_TIMEOUT)
369 .timeout(SEARCH_PROBE_TIMEOUT)
370 .user_agent(concat!("codewhale-doctor/", env!("CARGO_PKG_VERSION")))
371 .build()
372 {
373 Ok(client) => client,
374 Err(_) => {
375 return DoctorSearchProbeReport::Unreachable {
376 authority,
377 failure: DoctorSearchProbeFailure::Request,
378 };
379 }
380 };
381
382 match client.head(target.url).send().await {
383 Ok(response) => DoctorSearchProbeReport::Reachable {
384 authority,
385 status: response.status().as_u16(),
386 },
387 Err(error) => {
388 let failure = if error.is_timeout() {
389 DoctorSearchProbeFailure::Timeout
390 } else if error.is_connect() {
391 DoctorSearchProbeFailure::Connection
392 } else {
393 DoctorSearchProbeFailure::Request
394 };
395 DoctorSearchProbeReport::Unreachable { authority, failure }
396 }
397 }
398 }
399
400 /// Plain, bounded doctor copy for a search reachability result.
401 pub(crate) fn doctor_search_probe_lines(report: &DoctorSearchProbeReport) -> Vec<String> {
402 use crate::tools::web_search::SearchProbeTargetError;
403
404 match report {
405 DoctorSearchProbeReport::NotChecked => vec![
406 "· not checked (offline default)".to_string(),
407 "Run `codewhale doctor --probe-search` to test the selected provider authority."
408 .to_string(),
409 ],
410 DoctorSearchProbeReport::FeatureDisabled => vec![
411 "· not checked because the web_search feature is disabled".to_string(),
412 "Enable web_search before probing its provider.".to_string(),
413 ],
414 DoctorSearchProbeReport::ConfigurationError(error) => {
415 let detail = match error {
416 SearchProbeTargetError::Missing => {
417 "SearXNG requires an explicit [search] base_url"
418 }
419 SearchProbeTargetError::Unsupported => {
420 "[search] base_url is supported only for DuckDuckGo-compatible or SearXNG providers"
421 }
422 SearchProbeTargetError::Invalid => {
423 "the configured [search] base_url is not a valid HTTP(S) authority"
424 }
425 };
426 vec![format!("! not probed: {detail}")]
427 }
428 DoctorSearchProbeReport::PolicyDenied { authority } => vec![
429 format!("! {authority} blocked by configured network policy"),
430 "The same policy blocks web_search; update /network only if this authority should be allowed."
431 .to_string(),
432 ],
433 DoctorSearchProbeReport::PolicyApprovalRequired { authority } => vec![
434 format!("! {authority} requires network approval"),
435 "Allow the authority with /network, then rerun this probe.".to_string(),
436 ],
437 DoctorSearchProbeReport::Reachable { authority, status } => vec![
438 format!("✓ {authority} responded (HTTP {status})"),
439 "Transport only; authentication and search results were not tested.".to_string(),
440 ],
441 DoctorSearchProbeReport::Unreachable { authority, failure } => {
442 let detail = match failure {
443 DoctorSearchProbeFailure::Timeout => "timed out",
444 DoctorSearchProbeFailure::Connection => "connection, DNS, or TLS setup failed",
445 DoctorSearchProbeFailure::Request => "request failed",
446 };
447 vec![
448 format!("✗ {authority} {detail}"),
449 "No search API key or query was sent; provider error details were omitted."
450 .to_string(),
451 ]
452 }
453 }
454 }
455
456 #[derive(Debug, Clone, PartialEq, Eq)]
457 enum DoctorUpdateReport {
458 NotChecked,
459 UpdateAvailable { latest: String },
460 UpToDate { latest: String },
461 CurrentNewer { latest: String },
462 ReleaseMetadataInvalid,
463 ReleaseCheckFailed,
464 }
465
466 fn doctor_update_report<E>(
467 current_version: &str,
468 latest_result: Result<String, E>,
469 ) -> DoctorUpdateReport {
470 let Ok(raw_latest) = latest_result else {
471 return DoctorUpdateReport::ReleaseCheckFailed;
472 };
473 let Some(latest) = doctor_safe_release_tag(&raw_latest) else {
474 return DoctorUpdateReport::ReleaseMetadataInvalid;
475 };
476 match codewhale_release::compare_release_versions(current_version, &latest) {
477 Ok(std::cmp::Ordering::Less) => DoctorUpdateReport::UpdateAvailable { latest },
478 Ok(std::cmp::Ordering::Equal) => DoctorUpdateReport::UpToDate { latest },
479 Ok(std::cmp::Ordering::Greater) => DoctorUpdateReport::CurrentNewer { latest },
480 Err(_) => DoctorUpdateReport::ReleaseMetadataInvalid,
481 }
482 }
483
484 /// Canonicalize a release tag before any doctor renderer sees it. A release
485 /// server response is untrusted input: it may not become an error echo or a
486 /// terminal control sequence merely because the user opted into an update
487 /// check.
488 fn doctor_safe_release_tag(raw: &str) -> Option<String> {
489 let version = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
490 semver::Version::parse(version)
491 .ok()
492 .map(|version| format!("v{version}"))
493 }
494
495 fn doctor_update_report_lines(report: &DoctorUpdateReport) -> Vec<String> {
496 match report {
497 DoctorUpdateReport::NotChecked => vec![
498 "latest: unknown (not checked; offline default)".to_string(),
499 "Run `codewhale doctor --check-updates` to opt in.".to_string(),
500 ],
501 DoctorUpdateReport::UpdateAvailable { latest } => vec![
502 format!("latest: {latest}"),
503 "Update available. Run `codewhale update` to install.".to_string(),
504 ],
505 DoctorUpdateReport::UpToDate { latest } => {
506 vec![
507 format!("latest: {latest}"),
508 "Already up to date.".to_string(),
509 ]
510 }
511 DoctorUpdateReport::CurrentNewer { latest } => vec![
512 format!("latest: {latest}"),
513 "Current build is newer than the latest published release.".to_string(),
514 ],
515 DoctorUpdateReport::ReleaseMetadataInvalid => vec![
516 "latest: unknown (release metadata invalid; details omitted)".to_string(),
517 "Run `codewhale update --check` to retry.".to_string(),
518 ],
519 DoctorUpdateReport::ReleaseCheckFailed => vec![
520 "latest: unknown (release check failed; details omitted)".to_string(),
521 "Run `codewhale update --check` to retry.".to_string(),
522 ],
523 }
524 }
525
526 /// Print the update portion of the human doctor report.
527 ///
528 /// The release service is contacted only when `--check-updates` populated the
529 /// explicit request bit. The default branch returns before constructing an
530 /// HTTP request. Failure details are deliberately typed and generic because
531 /// transport errors and release metadata are untrusted strings.
532 pub(crate) async fn print_update_report(probes: DoctorProbeRequest) {
533 let current_version = env!("CARGO_PKG_VERSION");
534 println!(" · current: v{current_version}");
535 let report = if probes.should_check_updates() {
536 doctor_update_report(
537 current_version,
538 codewhale_release::latest_release_tag_async(codewhale_release::ReleaseChannel::Stable)
539 .await,
540 )
541 } else {
542 DoctorUpdateReport::NotChecked
543 };
544 for (index, line) in doctor_update_report_lines(&report).into_iter().enumerate() {
545 let indent = if index == 0 { " ·" } else { " " };
546 println!("{indent} {line}");
547 }
548 }
549
550 pub(crate) fn is_keyless_ds4_route(config: &crate::config::Config) -> bool {
551 config
552 .provider
553 .as_deref()
554 .is_some_and(|provider| provider.eq_ignore_ascii_case("ds4"))
555 && crate::config::base_url_uses_local_host(&config.active_route_base_url())
556 && crate::config::auth_mode_disables_api_key(
557 config
558 .auth_mode_for_provider(config.api_provider())
559 .as_deref(),
560 )
561 }
562
563 /// Probe DS4 through its cheap `/v1/models` contract instead of waking the
564 /// model for a completion. The selected model must be advertised.
565 pub(crate) async fn probe_ds4_models(config: &crate::config::Config) -> anyhow::Result<()> {
566 use crate::client::CodewhaleClient;
567 use crate::core::model_client::ModelClient;
568
569 let endpoint = crate::client::redact_url_for_display(&config.active_route_base_url());
570 let client = CodewhaleClient::new(config)?;
571 let configured_alias = client.model().to_string();
572 let models = match tokio::time::timeout(
573 std::time::Duration::from_secs(15),
574 client.list_models(),
575 )
576 .await
577 {
578 Ok(Ok(models)) => models,
579 Ok(Err(error)) => anyhow::bail!(ds4_probe_error(config, &error.to_string())),
580 Err(_) => anyhow::bail!("DS4 /v1/models timed out after 15 seconds at {}", endpoint),
581 };
582 if !models
583 .iter()
584 .any(|available| available.id == configured_alias)
585 {
586 let advertised = models
587 .iter()
588 .take(8)
589 .map(|available| available.id.as_str())
590 .collect::<Vec<_>>()
591 .join(", ");
592 anyhow::bail!(
593 "DS4 /v1/models at {} did not list configured alias '{configured_alias}' (advertised: {})",
594 endpoint,
595 if advertised.is_empty() {
596 "none"
597 } else {
598 advertised.as_str()
599 }
600 );
601 }
602 Ok(())
603 }
604
605 fn ds4_probe_error(config: &crate::config::Config, error: &str) -> String {
606 let endpoint = crate::client::redact_url_for_display(&config.active_route_base_url());
607 let status = error
608 .split_whitespace()
609 .collect::<Vec<_>>()
610 .windows(2)
611 .find_map(|pair| {
612 (pair[0].eq_ignore_ascii_case("HTTP")
613 && pair[1].trim_matches(|ch: char| !ch.is_ascii_digit()).len() == 3)
614 .then(|| {
615 pair[1]
616 .trim_matches(|ch: char| !ch.is_ascii_digit())
617 .to_string()
618 })
619 });
620 match status {
621 Some(status) => format!("DS4 /v1/models returned HTTP {status} at {}", endpoint),
622 None => format!(
623 "DS4 /v1/models could not be reached at {} (is ds4-server running?)",
624 endpoint
625 ),
626 }
627 }
628
629 #[cfg(test)]
630 #[path = "doctor/tests.rs"]
631 mod tests;
632
632 lines RUST