1use crate::config::Config;
4use serde::Serialize;
5use std::path::Path;
6
7#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case")]
9pub enum Severity {
10 Info,
11 Warn,
12 Critical,
13}
14
15#[derive(Debug, Clone, Serialize)]
16pub struct Finding {
17 pub code: &'static str,
18 pub severity: Severity,
19 pub title: &'static str,
20 pub detail: String,
21 pub remediation: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct Check {
26 pub name: String,
27 pub ok: bool,
28 pub detail: String,
29 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
30 pub soft_warn: bool,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct DoctorReport {
35 pub findings: Vec<Finding>,
36 pub checks: Vec<Check>,
37}
38
39pub fn audit_config(cfg: &Config) -> Vec<Finding> {
40 let mut findings = Vec::new();
41
42 if cfg.policy.allow_shell {
43 findings.push(Finding {
44 code: "policy_shell_enabled",
45 severity: Severity::Info,
46 title: "Shell execution is enabled",
47 detail: "The exec tool can run host commands when invoked by the agent.".into(),
48 remediation: Some(
49 "Leave enabled only if this deployment is intended to be a full-computer agent."
50 .into(),
51 ),
52 });
53 }
54
55 if cfg.policy.allow_dynamic_tools {
56 findings.push(Finding {
57 code: "policy_dynamic_tools_enabled",
58 severity: Severity::Warn,
59 title: "Dynamic tool creation/execution is enabled",
60 detail: "The agent can create and execute custom tools at runtime.".into(),
61 remediation: Some("Disable policy.allow_dynamic_tools for deployments that do not need self-extending tools.".into()),
62 });
63 }
64
65 if cfg.policy.allow_plugin_shell {
66 findings.push(Finding {
67 code: "policy_plugin_shell_enabled",
68 severity: Severity::Warn,
69 title: "Plugin shell execution is enabled",
70 detail: "Plugins are allowed to spawn shell commands.".into(),
71 remediation: Some(
72 "Disable policy.allow_plugin_shell unless plugins are trusted.".into(),
73 ),
74 });
75 }
76
77 if cfg.policy.allow_plugin_git {
78 findings.push(Finding {
79 code: "policy_plugin_git_enabled",
80 severity: Severity::Warn,
81 title: "Plugin git execution is enabled",
82 detail: "Plugins are allowed to run git operations directly.".into(),
83 remediation: Some("Disable policy.allow_plugin_git unless plugins are trusted.".into()),
84 });
85 }
86
87 if !Path::new(&cfg.workspace).exists() {
88 findings.push(Finding {
89 code: "workspace_missing",
90 severity: Severity::Warn,
91 title: "Configured workspace does not exist",
92 detail: format!(
93 "workspace=\"{}\" does not exist on disk.",
94 cfg.workspace.display()
95 ),
96 remediation: Some(
97 "Set workspace to an existing directory before starting the agent.".into(),
98 ),
99 });
100 }
101
102 if cfg.provider.api_key.is_none() && std::env::var("OPENAI_API_KEY").is_err() {
103 findings.push(Finding {
104 code: "provider_credentials_missing",
105 severity: Severity::Warn,
106 title: "No provider credentials detected",
107 detail: "No API key is configured in the config or common environment variables."
108 .into(),
109 remediation: Some(
110 "Set provider.api_key or export a provider API key before starting chat.".into(),
111 ),
112 });
113 }
114
115 findings
116}
117
118pub async fn collect_doctor_report(
119 cfg: Option<&Config>,
120 config_path: Option<&str>,
121 verbose: bool,
122) -> DoctorReport {
123 let mut checks = Vec::new();
124 if let Some(path) = config_path {
125 checks.push(check_config_file(path));
126 }
127 let cfg = cfg.cloned().unwrap_or_else(|| {
128 config_path
129 .and_then(|path| Config::load(path).ok())
130 .unwrap_or_else(Config::default_config)
131 });
132
133 for (bin, label) in [
134 ("git", "Git"),
135 ("cargo", "Rust toolchain"),
136 ("ffmpeg", "FFmpeg"),
137 ("docker", "Docker"),
138 ("node", "Node.js"),
139 ] {
140 let found = check_cmd(bin).await;
141 if found || verbose {
142 checks.push(Check {
143 name: label.to_string(),
144 ok: found,
145 detail: if found {
146 format!("{bin} is available")
147 } else {
148 format!("{bin} is not on PATH")
149 },
150 soft_warn: false,
151 });
152 }
153 }
154
155 let workspace_exists = cfg.workspace.exists();
156 checks.push(Check {
157 name: "Workspace".into(),
158 ok: workspace_exists,
159 detail: cfg.workspace.display().to_string(),
160 soft_warn: false,
161 });
162
163 let workspace_writable = workspace_exists && is_workspace_writable(&cfg.workspace);
164 checks.push(Check {
165 name: "Workspace writable".into(),
166 ok: workspace_writable,
167 detail: if !workspace_exists {
168 "workspace does not exist".into()
169 } else if workspace_writable {
170 "workspace is writable".into()
171 } else {
172 "workspace is not writable".into()
173 },
174 soft_warn: false,
175 });
176
177 checks.push(local_bin_path_check());
178 checks.push(shared_login_check());
179
180 let provider_key_present =
181 cfg.provider.api_key.is_some() || std::env::var("OPENAI_API_KEY").is_ok();
182 checks.push(Check {
183 name: "Provider credentials".into(),
184 ok: provider_key_present,
185 detail: if provider_key_present {
186 "API credentials detected".into()
187 } else {
188 "No provider credentials detected".into()
189 },
190 soft_warn: false,
191 });
192
193 DoctorReport {
194 findings: audit_config(&cfg),
195 checks,
196 }
197}
198
199pub fn render_findings(findings: &[Finding]) -> String {
200 if findings.is_empty() {
201 return "No audit findings.".into();
202 }
203
204 findings
205 .iter()
206 .map(|finding| {
207 let severity = match finding.severity {
208 Severity::Info => "INFO",
209 Severity::Warn => "WARN",
210 Severity::Critical => "CRITICAL",
211 };
212 match &finding.remediation {
213 Some(remediation) => format!(
214 "[{severity}] {} ({})\n{}\nRemediation: {}",
215 finding.title, finding.code, finding.detail, remediation
216 ),
217 None => format!(
218 "[{severity}] {} ({})\n{}",
219 finding.title, finding.code, finding.detail
220 ),
221 }
222 })
223 .collect::<Vec<_>>()
224 .join("\n\n")
225}
226
227pub fn render_doctor_report(report: &DoctorReport) -> String {
228 let mut out = vec![
229 "apollo doctor".to_string(),
230 String::new(),
231 "Checks:".to_string(),
232 ];
233 for check in &report.checks {
234 let icon = if check.ok {
235 "OK"
236 } else if check.soft_warn {
237 "WARN"
238 } else {
239 "FAIL"
240 };
241 out.push(format!("- [{icon}] {}: {}", check.name, check.detail));
242 }
243 out.push(String::new());
244 out.push("Audit:".to_string());
245 out.push(render_findings(&report.findings));
246 out.join("\n")
247}
248
249pub(crate) fn check_config_file(path: &str) -> Check {
250 let file = Path::new(path);
251 if !file.exists() {
252 return Check {
253 name: "Config file".into(),
254 ok: false,
255 detail: format!("{path} not found"),
256 soft_warn: false,
257 };
258 }
259 match Config::load(path) {
260 Ok(_) => Check {
261 name: "Config file".into(),
262 ok: true,
263 detail: format!("{path} parses OK"),
264 soft_warn: false,
265 },
266 Err(err) => Check {
267 name: "Config file".into(),
268 ok: false,
269 detail: format!("{path} invalid: {err}"),
270 soft_warn: false,
271 },
272 }
273}
274
275fn is_workspace_writable(path: &Path) -> bool {
276 let probe = path.join(format!(".apollo-write-test-{}", std::process::id()));
277 match std::fs::File::create(&probe) {
278 Ok(_) => {
279 let _ = std::fs::remove_file(probe);
280 true
281 }
282 Err(_) => false,
283 }
284}
285
286#[cfg(feature = "rs-ai")]
291fn shared_login_check() -> Check {
292 let logins = crate::providers::shared_credentials::logins();
293 if logins.is_empty() {
294 return Check {
295 name: "Shared login (rs_ai)".into(),
296 ok: false,
297 detail: "no provider logged in to the shared credential store".into(),
298 soft_warn: true,
299 };
300 }
301
302 let detail = logins
303 .iter()
304 .map(|login| match (login.expired, login.refreshable) {
305 (false, _) => login.provider.to_string(),
306 (true, true) => format!("{} (expired, refreshable)", login.provider),
307 (true, false) => format!("{} (expired)", login.provider),
308 })
309 .collect::<Vec<_>>()
310 .join(", ");
311
312 let usable = logins.iter().any(|l| !l.expired || l.refreshable);
316 Check {
317 name: "Shared login (rs_ai)".into(),
318 ok: usable,
319 detail,
320 soft_warn: !usable,
321 }
322}
323
324#[cfg(not(feature = "rs-ai"))]
325fn shared_login_check() -> Check {
326 Check {
327 name: "Shared login (rs_ai)".into(),
328 ok: false,
329 detail: "built without the rs-ai feature".into(),
330 soft_warn: true,
331 }
332}
333
334fn local_bin_path_check() -> Check {
335 let on_path = local_bin_on_path();
336 Check {
337 name: "~/.local/bin on PATH".into(),
338 ok: on_path,
339 detail: if on_path {
340 "~/.local/bin is on PATH".into()
341 } else {
342 "~/.local/bin is not on PATH (optional)".into()
343 },
344 soft_warn: true,
345 }
346}
347
348fn local_bin_on_path() -> bool {
349 let Ok(home) = std::env::var("HOME") else {
350 return false;
351 };
352 let local_bin = Path::new(&home).join(".local/bin");
353 let Ok(path_env) = std::env::var("PATH") else {
354 return false;
355 };
356 path_env
357 .split(':')
358 .any(|entry| Path::new(entry) == local_bin.as_path())
359}
360
361async fn check_cmd(cmd: &str) -> bool {
362 tokio::process::Command::new("which")
363 .arg(cmd)
364 .output()
365 .await
366 .map(|output| output.status.success())
367 .unwrap_or(false)
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn render_doctor_report_marks_soft_warn_and_fail() {
376 let report = DoctorReport {
377 findings: vec![],
378 checks: vec![
379 Check {
380 name: "passing".into(),
381 ok: true,
382 detail: "all good".into(),
383 soft_warn: false,
384 },
385 Check {
386 name: "optional missing".into(),
387 ok: false,
388 detail: "not on PATH (optional)".into(),
389 soft_warn: true,
390 },
391 Check {
392 name: "required missing".into(),
393 ok: false,
394 detail: "not found".into(),
395 soft_warn: false,
396 },
397 ],
398 };
399 let rendered = render_doctor_report(&report);
400 assert!(rendered.contains("- [OK] passing: all good"));
401 assert!(rendered.contains("- [WARN] optional missing: not on PATH (optional)"));
402 assert!(rendered.contains("- [FAIL] required missing: not found"));
403 }
404
405 #[test]
406 fn check_config_file_reports_missing_path() {
407 let path = "/tmp/apollo-doctor-missing-config-test-xyz123.json";
408 let check = check_config_file(path);
409 assert!(!check.ok);
410 assert!(!check.soft_warn);
411 assert_eq!(check.name, "Config file");
412 assert!(check.detail.contains("not found"));
413 }
414
415 #[test]
416 fn check_config_file_reports_valid_config() {
417 let dir = tempfile::tempdir().unwrap();
418 let path = dir.path().join("apollo.json");
419 std::fs::write(&path, "{}").unwrap();
420 let check = check_config_file(path.to_str().unwrap());
421 assert!(check.ok);
422 assert!(!check.soft_warn);
423 assert!(check.detail.contains("parses OK"));
424 }
425}