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()
103 && std::env::var("ANTHROPIC_API_KEY").is_err()
104 && std::env::var("OPENAI_API_KEY").is_err()
105 {
106 findings.push(Finding {
107 code: "provider_credentials_missing",
108 severity: Severity::Warn,
109 title: "No provider credentials detected",
110 detail: "No API key is configured in the config or common environment variables."
111 .into(),
112 remediation: Some(
113 "Set provider.api_key or export a provider API key before starting chat.".into(),
114 ),
115 });
116 }
117
118 findings
119}
120
121pub async fn collect_doctor_report(
122 cfg: Option<&Config>,
123 config_path: Option<&str>,
124 verbose: bool,
125) -> DoctorReport {
126 let mut checks = Vec::new();
127 if let Some(path) = config_path {
128 checks.push(check_config_file(path));
129 }
130 let cfg = cfg.cloned().unwrap_or_else(|| {
131 config_path
132 .and_then(|path| Config::load(path).ok())
133 .unwrap_or_else(Config::default_config)
134 });
135
136 for (bin, label) in [
137 ("git", "Git"),
138 ("cargo", "Rust toolchain"),
139 ("ffmpeg", "FFmpeg"),
140 ("docker", "Docker"),
141 ("node", "Node.js"),
142 ] {
143 let found = check_cmd(bin).await;
144 if found || verbose {
145 checks.push(Check {
146 name: label.to_string(),
147 ok: found,
148 detail: if found {
149 format!("{bin} is available")
150 } else {
151 format!("{bin} is not on PATH")
152 },
153 soft_warn: false,
154 });
155 }
156 }
157
158 let workspace_exists = cfg.workspace.exists();
159 checks.push(Check {
160 name: "Workspace".into(),
161 ok: workspace_exists,
162 detail: cfg.workspace.display().to_string(),
163 soft_warn: false,
164 });
165
166 let workspace_writable = workspace_exists && is_workspace_writable(&cfg.workspace);
167 checks.push(Check {
168 name: "Workspace writable".into(),
169 ok: workspace_writable,
170 detail: if !workspace_exists {
171 "workspace does not exist".into()
172 } else if workspace_writable {
173 "workspace is writable".into()
174 } else {
175 "workspace is not writable".into()
176 },
177 soft_warn: false,
178 });
179
180 checks.push(local_bin_path_check());
181 checks.push(shared_login_check());
182
183 let provider_key_present = cfg.provider.api_key.is_some()
184 || std::env::var("ANTHROPIC_API_KEY").is_ok()
185 || std::env::var("OPENAI_API_KEY").is_ok();
186 checks.push(Check {
187 name: "Provider credentials".into(),
188 ok: provider_key_present,
189 detail: if provider_key_present {
190 "API credentials detected".into()
191 } else {
192 "No provider credentials detected".into()
193 },
194 soft_warn: false,
195 });
196
197 DoctorReport {
198 findings: audit_config(&cfg),
199 checks,
200 }
201}
202
203pub fn render_findings(findings: &[Finding]) -> String {
204 if findings.is_empty() {
205 return "No audit findings.".into();
206 }
207
208 findings
209 .iter()
210 .map(|finding| {
211 let severity = match finding.severity {
212 Severity::Info => "INFO",
213 Severity::Warn => "WARN",
214 Severity::Critical => "CRITICAL",
215 };
216 match &finding.remediation {
217 Some(remediation) => format!(
218 "[{severity}] {} ({})\n{}\nRemediation: {}",
219 finding.title, finding.code, finding.detail, remediation
220 ),
221 None => format!(
222 "[{severity}] {} ({})\n{}",
223 finding.title, finding.code, finding.detail
224 ),
225 }
226 })
227 .collect::<Vec<_>>()
228 .join("\n\n")
229}
230
231pub fn render_doctor_report(report: &DoctorReport) -> String {
232 let mut out = vec![
233 "apollo doctor".to_string(),
234 String::new(),
235 "Checks:".to_string(),
236 ];
237 for check in &report.checks {
238 let icon = if check.ok {
239 "OK"
240 } else if check.soft_warn {
241 "WARN"
242 } else {
243 "FAIL"
244 };
245 out.push(format!("- [{icon}] {}: {}", check.name, check.detail));
246 }
247 out.push(String::new());
248 out.push("Audit:".to_string());
249 out.push(render_findings(&report.findings));
250 out.join("\n")
251}
252
253pub(crate) fn check_config_file(path: &str) -> Check {
254 let file = Path::new(path);
255 if !file.exists() {
256 return Check {
257 name: "Config file".into(),
258 ok: false,
259 detail: format!("{path} not found"),
260 soft_warn: false,
261 };
262 }
263 match Config::load(path) {
264 Ok(_) => Check {
265 name: "Config file".into(),
266 ok: true,
267 detail: format!("{path} parses OK"),
268 soft_warn: false,
269 },
270 Err(err) => Check {
271 name: "Config file".into(),
272 ok: false,
273 detail: format!("{path} invalid: {err}"),
274 soft_warn: false,
275 },
276 }
277}
278
279fn is_workspace_writable(path: &Path) -> bool {
280 let probe = path.join(format!(".apollo-write-test-{}", std::process::id()));
281 match std::fs::File::create(&probe) {
282 Ok(_) => {
283 let _ = std::fs::remove_file(probe);
284 true
285 }
286 Err(_) => false,
287 }
288}
289
290#[cfg(feature = "rs-ai")]
295fn shared_login_check() -> Check {
296 let logins = crate::providers::shared_credentials::logins();
297 if logins.is_empty() {
298 return Check {
299 name: "Shared login (rs_ai)".into(),
300 ok: false,
301 detail: "no provider logged in to the shared credential store".into(),
302 soft_warn: true,
303 };
304 }
305
306 let detail = logins
307 .iter()
308 .map(|login| match (login.expired, login.refreshable) {
309 (false, _) => login.provider.to_string(),
310 (true, true) => format!("{} (expired, refreshable)", login.provider),
311 (true, false) => format!("{} (expired)", login.provider),
312 })
313 .collect::<Vec<_>>()
314 .join(", ");
315
316 let usable = logins.iter().any(|l| !l.expired || l.refreshable);
320 Check {
321 name: "Shared login (rs_ai)".into(),
322 ok: usable,
323 detail,
324 soft_warn: !usable,
325 }
326}
327
328#[cfg(not(feature = "rs-ai"))]
329fn shared_login_check() -> Check {
330 Check {
331 name: "Shared login (rs_ai)".into(),
332 ok: false,
333 detail: "built without the rs-ai feature".into(),
334 soft_warn: true,
335 }
336}
337
338fn local_bin_path_check() -> Check {
339 let on_path = local_bin_on_path();
340 Check {
341 name: "~/.local/bin on PATH".into(),
342 ok: on_path,
343 detail: if on_path {
344 "~/.local/bin is on PATH".into()
345 } else {
346 "~/.local/bin is not on PATH (optional)".into()
347 },
348 soft_warn: true,
349 }
350}
351
352fn local_bin_on_path() -> bool {
353 let Ok(home) = std::env::var("HOME") else {
354 return false;
355 };
356 let local_bin = Path::new(&home).join(".local/bin");
357 let Ok(path_env) = std::env::var("PATH") else {
358 return false;
359 };
360 path_env
361 .split(':')
362 .any(|entry| Path::new(entry) == local_bin.as_path())
363}
364
365async fn check_cmd(cmd: &str) -> bool {
366 tokio::process::Command::new("which")
367 .arg(cmd)
368 .output()
369 .await
370 .map(|output| output.status.success())
371 .unwrap_or(false)
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn render_doctor_report_marks_soft_warn_and_fail() {
380 let report = DoctorReport {
381 findings: vec![],
382 checks: vec![
383 Check {
384 name: "passing".into(),
385 ok: true,
386 detail: "all good".into(),
387 soft_warn: false,
388 },
389 Check {
390 name: "optional missing".into(),
391 ok: false,
392 detail: "not on PATH (optional)".into(),
393 soft_warn: true,
394 },
395 Check {
396 name: "required missing".into(),
397 ok: false,
398 detail: "not found".into(),
399 soft_warn: false,
400 },
401 ],
402 };
403 let rendered = render_doctor_report(&report);
404 assert!(rendered.contains("- [OK] passing: all good"));
405 assert!(rendered.contains("- [WARN] optional missing: not on PATH (optional)"));
406 assert!(rendered.contains("- [FAIL] required missing: not found"));
407 }
408
409 #[test]
410 fn check_config_file_reports_missing_path() {
411 let path = "/tmp/apollo-doctor-missing-config-test-xyz123.json";
412 let check = check_config_file(path);
413 assert!(!check.ok);
414 assert!(!check.soft_warn);
415 assert_eq!(check.name, "Config file");
416 assert!(check.detail.contains("not found"));
417 }
418
419 #[test]
420 fn check_config_file_reports_valid_config() {
421 let dir = tempfile::tempdir().unwrap();
422 let path = dir.path().join("apollo.json");
423 std::fs::write(&path, "{}").unwrap();
424 let check = check_config_file(path.to_str().unwrap());
425 assert!(check.ok);
426 assert!(!check.soft_warn);
427 assert!(check.detail.contains("parses OK"));
428 }
429}