Skip to main content

talon/
doctor.rs

1//! `talon doctor`: one command that explains why Talon can or cannot run.
2//!
3//! Prints machine-readable blocks (same format as `talon setup`):
4//! `CHECK_ENVIRONMENT` and `CHECK_VLLM` from `talon-setup`, then
5//! `CHECK_GATEWAY` (bind address the LLM gateway would use and whether
6//! containers can reach it), `CHECK_EXPOSURE` (vLLM API key requirement,
7//! HTTP API bind address — MYTH-05), `CHECK_NETWORK` (the dedicated agent
8//! network exists — MYTH-05), `AUDIT_VERIFY`, and a final `DOCTOR`
9//! summary with one hint per problem. Exit code 0 when no problem was
10//! found.
11
12use std::net::{IpAddr, Ipv4Addr};
13use std::path::Path;
14use std::process::Stdio;
15use std::time::Duration;
16
17use talon_container::runtime::network_inspect_args;
18use talon_container::{ContainerRuntime, HostPlatform, RunnerConfig};
19use talon_core::config::TalonConfig;
20use talon_core::provider::effective_vllm;
21use talon_db::{Db, DbOptions, default_db_path};
22use talon_http::HttpConfig;
23use talon_setup::checks::check_port;
24use talon_setup::{SetupContext, StatusBlock, Step, StepArgs, run_step};
25
26use crate::app::{ApiKeyPolicy, DEFAULT_CONTAINER_HOST, api_key_policy, gateway_bind_ip};
27
28/// Operator hint for an issue code.
29pub fn hint(issue: &str) -> &'static str {
30    match issue {
31        "container_runtime_missing" => "install docker or podman (CONTAINER_RUNTIME selects one)",
32        "container_runtime_not_running" => {
33            "start the container daemon (`docker info` must succeed)"
34        }
35        "configured_runtime_unavailable" => {
36            "CONTAINER_RUNTIME is not installed; another runtime was found and will be used"
37        }
38        "image_missing" => {
39            "build the agent image: ./container/build.sh (or `talon setup container`)"
40        }
41        "vllm_not_configured" => {
42            "set TALON_VLLM_BASE_URL and TALON_VLLM_MODEL in .env (see .env.example); without them \
43             the LLM gateway does not start and every agent run fails"
44        }
45        "vllm_invalid_url" => "TALON_VLLM_BASE_URL must be an http(s) URL, e.g. http://vllm:8000",
46        "vllm_unreachable" => {
47            "vLLM is not reachable from this host (network, firewall, server down)"
48        }
49        "vllm_auth_failed" => {
50            "vLLM rejected the key: set TALON_VLLM_API_KEY in .env to vLLM's --api-key"
51        }
52        "vllm_api_key_unset" => {
53            "TALON_VLLM_API_KEY is not set, so the LLM gateway refuses to start (without a key \
54             containers could call vLLM directly and bypass the gateway): start vLLM with \
55             --api-key and put the same value in .env; only if containers provably cannot reach \
56             vLLM, set TALON_VLLM_ALLOW_NO_API_KEY=true"
57        }
58        "vllm_model_missing" => {
59            "TALON_VLLM_MODEL is not served by vLLM (compare with --served-model-name / MODELS)"
60        }
61        "vllm_http_error" | "vllm_invalid_response" => {
62            "vLLM answered /v1/models unexpectedly; check TALON_VLLM_BASE_URL points at vLLM"
63        }
64        "gateway_port_in_use" => {
65            "TALON_LLM_GATEWAY_PORT is in use (expected while talon is running; otherwise pick another port)"
66        }
67        "gateway_port_unavailable" | "gateway_bind_unavailable" => {
68            "the LLM gateway cannot bind its address: set TALON_LLM_GATEWAY_BIND to a local address"
69        }
70        "gateway_bind_loopback" => {
71            "on Linux, containers cannot reach a gateway bound to 127.0.0.1: set \
72             TALON_LLM_GATEWAY_BIND to the docker bridge address (e.g. 172.17.0.1)"
73        }
74        "agent_network_missing" => {
75            "the agent container network (talon-agents) does not exist: run `talon setup egress` \
76             (creates it with inter-container traffic disabled plus the egress firewall rules); \
77             `talon run` also creates the network at startup, but not the firewall rules"
78        }
79        "agent_network_unchecked" => {
80            "the agent container network could not be inspected (is the container runtime running?)"
81        }
82        "dirs_not_writable" => {
83            "store/, data/, groups/ and logs/ must be writable by the service user"
84        }
85        "low_disk_space" => "free some disk space (images, logs, data/)",
86        "http_api_public_bind" => {
87            "the CoPilot HTTP API listens on every interface (COPILOT_HTTP_BIND=0.0.0.0 / ::): \
88             bind it to 127.0.0.1 or the private address CoPilot uses, and firewall the port"
89        }
90        "http_config_invalid" => {
91            "a COPILOT_HTTP_* setting is invalid (see CHECK_EXPOSURE ERROR); `talon run` refuses to start"
92        }
93        "http_api_key_unset" => {
94            "HTTP_API_KEY is not set: the CoPilot HTTP API rejects every request (openssl rand -hex 32)"
95        }
96        "audit_chain_tampered" => {
97            "the audit log hash chain is broken: investigate with `talon audit tail` before trusting it"
98        }
99        _ => "see the blocks above",
100    }
101}
102
103fn issues_of(block: Option<&StatusBlock>) -> Vec<String> {
104    block
105        .and_then(|b| b.get("ISSUES"))
106        .filter(|v| *v != "none" && !v.is_empty())
107        .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
108        .unwrap_or_default()
109}
110
111/// `CHECK_GATEWAY` block plus issue codes.
112async fn gateway_block(config: &TalonConfig) -> (StatusBlock, Vec<String>) {
113    let mut issues = Vec::new();
114    let mut block = StatusBlock::new("CHECK_GATEWAY");
115    let explicit = config.env.get("TALON_LLM_GATEWAY_BIND").is_some();
116    block.set(
117        "BIND_SOURCE",
118        if explicit { "explicit" } else { "detected" },
119    );
120    let runtime = ContainerRuntime::detect(&config.container_runtime).ok();
121    let ip: Option<IpAddr> = match &runtime {
122        Some(rt) => match gateway_bind_ip(config, rt).await {
123            Ok(ip) => Some(ip),
124            Err(e) => {
125                block.set("ERROR", format!("{e:#}"));
126                issues.push("gateway_bind_unavailable".to_string());
127                None
128            }
129        },
130        None => config
131            .env
132            .get("TALON_LLM_GATEWAY_BIND")
133            .and_then(|v| v.trim().parse().ok())
134            .or(Some(IpAddr::V4(Ipv4Addr::LOCALHOST))),
135    };
136    if let Some(ip) = ip {
137        block.set("BIND", ip);
138        block.set("PORT", config.llm_gateway_port);
139        let port = check_port(&ip.to_string(), config.llm_gateway_port).await;
140        block.set("PORT_STATUS", port.as_str());
141        match port.as_str() {
142            "free" => {}
143            "in_use" => issues.push("gateway_port_in_use".to_string()),
144            _ => issues.push("gateway_bind_unavailable".to_string()),
145        }
146        let linux = runtime
147            .as_ref()
148            .is_some_and(|rt| rt.platform() == HostPlatform::Linux);
149        if linux && ip.is_loopback() {
150            issues.push("gateway_bind_loopback".to_string());
151        }
152    }
153    let host = config
154        .env
155        .get("TALON_LLM_GATEWAY_HOST")
156        .unwrap_or_else(|| DEFAULT_CONTAINER_HOST.to_string());
157    block.set(
158        "CONTAINER_URL",
159        format!("http://{host}:{}/v1", config.llm_gateway_port),
160    );
161    block.set(
162        "STATUS",
163        if issues.is_empty() {
164            "success"
165        } else {
166            "failed"
167        },
168    );
169    (block, issues)
170}
171
172/// `CHECK_EXPOSURE` block plus issue codes: the vLLM API key requirement
173/// (only when vLLM is configured — otherwise `vllm_not_configured` already
174/// says what is missing) and the HTTP API bind address (MYTH-05).
175pub fn exposure_block(config: &TalonConfig) -> (StatusBlock, Vec<String>) {
176    let mut issues = Vec::new();
177    let mut block = StatusBlock::new("CHECK_EXPOSURE");
178    let key = match api_key_policy(config) {
179        ApiKeyPolicy::Present => "set",
180        ApiKeyPolicy::MissingAllowed => "unset_allowed",
181        ApiKeyPolicy::Missing => {
182            if effective_vllm(&config.provider_defaults).is_some() {
183                issues.push("vllm_api_key_unset".to_string());
184            }
185            "unset"
186        }
187    };
188    block.set("VLLM_API_KEY", key);
189    match HttpConfig::from_talon_config(config) {
190        Ok(http) => {
191            block.set("HTTP_BIND", http.bind);
192            block.set("HTTP_PORT", http.port);
193            block.set("HTTP_TLS_TERMINATED", http.tls_terminated);
194            if http.bind.is_unspecified() {
195                issues.push("http_api_public_bind".to_string());
196            }
197        }
198        Err(e) => {
199            block.set("ERROR", e);
200            issues.push("http_config_invalid".to_string());
201        }
202    }
203    block.set(
204        "STATUS",
205        if issues.is_empty() {
206            "success"
207        } else {
208            "failed"
209        },
210    );
211    (block, issues)
212}
213
214/// How long `<runtime> network inspect` may take.
215const NETWORK_INSPECT_TIMEOUT: Duration = Duration::from_secs(20);
216
217/// Result of `<runtime> network inspect <name>`.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum NetworkState {
220    /// The network exists.
221    Exists,
222    /// The runtime says there is no such network.
223    Missing,
224    /// The inspection failed for another reason.
225    Unknown,
226}
227
228/// Classify a `network inspect` outcome.
229pub fn classify_network_inspect(success: bool, stderr: &str) -> NetworkState {
230    if success {
231        return NetworkState::Exists;
232    }
233    let err = stderr.to_ascii_lowercase();
234    if err.contains("no such network")
235        || err.contains("not found")
236        || err.contains("unable to find network")
237    {
238        NetworkState::Missing
239    } else {
240        NetworkState::Unknown
241    }
242}
243
244async fn inspect_network(bin: &str, name: &str) -> Result<NetworkState, String> {
245    let child = tokio::process::Command::new(bin)
246        .args(network_inspect_args(name))
247        .stdin(Stdio::null())
248        .stdout(Stdio::null())
249        .stderr(Stdio::piped())
250        .kill_on_drop(true)
251        .output();
252    match tokio::time::timeout(NETWORK_INSPECT_TIMEOUT, child).await {
253        Ok(Ok(out)) => Ok(classify_network_inspect(
254            out.status.success(),
255            &String::from_utf8_lossy(&out.stderr),
256        )),
257        Ok(Err(e)) => Err(format!("cannot run {bin}: {e}")),
258        Err(_) => Err(format!("{bin} network inspect timed out")),
259    }
260}
261
262/// `CHECK_NETWORK` block plus issue codes: the network agent containers
263/// run on (`TALON_CONTAINER_NETWORK`, default `talon-agents`) must exist.
264/// Skipped without a container runtime (reported by the environment step).
265async fn network_block(config: &TalonConfig) -> Option<(StatusBlock, Vec<String>)> {
266    let runtime = ContainerRuntime::detect(&config.container_runtime).ok()?;
267    let mut block = StatusBlock::new("CHECK_NETWORK");
268    let mut issues = Vec::new();
269    match RunnerConfig::from_talon_config(config).default_network {
270        None => {
271            block.set("NETWORK", "runtime-default");
272            block.set(
273                "NOTE",
274                "TALON_CONTAINER_NETWORK selects the runtime default bridge: containers can reach \
275                 each other and `talon setup egress` rules do not apply",
276            );
277        }
278        Some(network) => {
279            block.set("NETWORK", &network);
280            match inspect_network(runtime.bin(), &network).await {
281                Ok(NetworkState::Exists) => block.set("EXISTS", "yes"),
282                Ok(NetworkState::Missing) => {
283                    block.set("EXISTS", "no");
284                    issues.push("agent_network_missing".to_string());
285                }
286                Ok(NetworkState::Unknown) => {
287                    block.set("EXISTS", "unknown");
288                    issues.push("agent_network_unchecked".to_string());
289                }
290                Err(e) => {
291                    block.set("EXISTS", "unknown");
292                    block.set("ERROR", e);
293                    issues.push("agent_network_unchecked".to_string());
294                }
295            }
296        }
297    }
298    block.set(
299        "STATUS",
300        if issues.is_empty() {
301            "success"
302        } else {
303            "failed"
304        },
305    );
306    Some((block, issues))
307}
308
309/// `AUDIT_VERIFY` block plus issue codes (skipped without a database).
310fn audit_block(config: &TalonConfig) -> Option<(StatusBlock, Vec<String>)> {
311    let path = default_db_path(&config.store_dir);
312    if !path.exists() {
313        return None;
314    }
315    let db = match Db::open_with_options(
316        &path,
317        &DbOptions {
318            assistant_name: config.assistant_name.clone(),
319        },
320    ) {
321        Ok(db) => db,
322        Err(e) => {
323            return Some((
324                StatusBlock::new("AUDIT_VERIFY")
325                    .field("STATUS", "failed")
326                    .field("ERROR", e),
327                vec!["audit_chain_tampered".to_string()],
328            ));
329        }
330    };
331    match crate::audit_cmd::verify_block(&db) {
332        Ok((block, true)) => Some((block, Vec::new())),
333        Ok((block, false)) => Some((block, vec!["audit_chain_tampered".to_string()])),
334        Err(e) => Some((
335            StatusBlock::new("AUDIT_VERIFY")
336                .field("STATUS", "failed")
337                .field("ERROR", format!("{e:#}")),
338            Vec::new(),
339        )),
340    }
341}
342
343/// The final `DOCTOR` block.
344pub fn summary_block(issues: &[String]) -> StatusBlock {
345    let mut block = StatusBlock::new("DOCTOR").field("VERSION", crate::VERSION);
346    if issues.is_empty() {
347        block.set("ISSUES", "none");
348        block.set("STATUS", "success");
349        return block;
350    }
351    block.set("ISSUES", issues.join(","));
352    for (i, issue) in issues.iter().enumerate() {
353        block.set(
354            &format!("HINT_{}", i + 1),
355            format!("{issue}: {}", hint(issue)),
356        );
357    }
358    block.set("STATUS", "failed");
359    block
360}
361
362/// Run every check and print the blocks. Returns the exit code.
363pub async fn run(project_root: &Path, config: &TalonConfig) -> i32 {
364    let ctx = SetupContext::detect(project_root);
365    let no_args = StepArgs::new(Vec::<String>::new());
366    let mut issues: Vec<String> = Vec::new();
367
368    let env = run_step(Step::Environment, &no_args, &ctx).await;
369    println!("{}", env.render());
370    issues.extend(issues_of(env.block.as_ref()));
371
372    let vllm = run_step(Step::Vllm, &no_args, &ctx).await;
373    println!("{}", vllm.render());
374
375    let (gateway, gw_issues) = gateway_block(config).await;
376    println!("{gateway}");
377    // The environment step already probed 127.0.0.1:<port>; the gateway
378    // block probes the real bind address, which supersedes it.
379    issues.retain(|i| !i.starts_with("gateway_port_"));
380    issues.extend(gw_issues);
381
382    let (exposure, exposure_issues) = exposure_block(config);
383    println!("{exposure}");
384    issues.extend(exposure_issues);
385
386    if let Some((network, network_issues)) = network_block(config).await {
387        println!("{network}");
388        issues.extend(network_issues);
389    }
390
391    if config.env.file_value("HTTP_API_KEY").is_none() {
392        issues.push("http_api_key_unset".to_string());
393    }
394
395    if let Some((block, audit_issues)) = audit_block(config) {
396        println!("{block}");
397        issues.extend(audit_issues);
398    }
399
400    let mut seen = std::collections::HashSet::new();
401    issues.retain(|i| seen.insert(i.clone()));
402    println!("{}", summary_block(&issues));
403    if issues.is_empty() { 0 } else { 1 }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn issue_parsing_and_summary() {
412        let block = StatusBlock::new("CHECK_ENVIRONMENT")
413            .field("ISSUES", "image_missing,vllm_not_configured");
414        let issues = issues_of(Some(&block));
415        assert_eq!(issues, vec!["image_missing", "vllm_not_configured"]);
416        assert!(issues_of(Some(&StatusBlock::new("X").field("ISSUES", "none"))).is_empty());
417        assert!(issues_of(None).is_empty());
418
419        let summary = summary_block(&issues);
420        assert_eq!(summary.get("STATUS"), Some("failed"));
421        assert!(
422            summary
423                .get("HINT_2")
424                .unwrap()
425                .contains("TALON_VLLM_BASE_URL")
426        );
427        assert_eq!(summary_block(&[]).get("STATUS"), Some("success"));
428    }
429
430    fn cfg(content: &str) -> TalonConfig {
431        TalonConfig::from_env(
432            Path::new("/tmp/talon-doctor-test"),
433            talon_core::env::EnvFile::from_str_content(content),
434        )
435    }
436
437    /// The process environment overrides `.env` for non-secret keys.
438    fn env_overridden(keys: &[&str]) -> bool {
439        keys.iter().any(|k| std::env::var(k).is_ok())
440    }
441
442    #[test]
443    fn exposure_flags_missing_vllm_key_and_public_http_bind() {
444        if env_overridden(&[
445            "TALON_VLLM_BASE_URL",
446            "TALON_VLLM_MODEL",
447            "TALON_VLLM_ALLOW_NO_API_KEY",
448            "COPILOT_HTTP_BIND",
449        ]) {
450            return;
451        }
452        const VLLM: &str = "TALON_VLLM_BASE_URL=http://vllm:8000\nTALON_VLLM_MODEL=q\n";
453
454        let (block, issues) = exposure_block(&cfg(&format!("{VLLM}COPILOT_HTTP_BIND=0.0.0.0\n")));
455        assert_eq!(issues, vec!["vllm_api_key_unset", "http_api_public_bind"]);
456        assert_eq!(block.get("VLLM_API_KEY"), Some("unset"));
457        assert_eq!(block.get("STATUS"), Some("failed"));
458        let summary = summary_block(&issues);
459        assert!(
460            summary
461                .get("HINT_1")
462                .unwrap()
463                .contains("TALON_VLLM_API_KEY")
464        );
465        assert!(summary.get("HINT_2").unwrap().contains("127.0.0.1"));
466
467        let (_, issues) = exposure_block(&cfg(&format!("{VLLM}COPILOT_HTTP_BIND=::\n")));
468        assert!(issues.contains(&"http_api_public_bind".to_string()));
469
470        let (block, issues) = exposure_block(&cfg(&format!(
471            "{VLLM}TALON_VLLM_API_KEY=k\nCOPILOT_HTTP_BIND=127.0.0.1\n"
472        )));
473        assert!(issues.is_empty(), "{issues:?}");
474        assert_eq!(block.get("VLLM_API_KEY"), Some("set"));
475        assert_eq!(block.get("STATUS"), Some("success"));
476
477        let (block, issues) = exposure_block(&cfg(&format!(
478            "{VLLM}TALON_VLLM_ALLOW_NO_API_KEY=true\nCOPILOT_HTTP_BIND=10.0.0.5\n"
479        )));
480        assert!(issues.is_empty(), "{issues:?}");
481        assert_eq!(block.get("VLLM_API_KEY"), Some("unset_allowed"));
482
483        // Without vLLM, `vllm_not_configured` (environment step) covers it.
484        let (_, issues) = exposure_block(&cfg("COPILOT_HTTP_BIND=127.0.0.1\n"));
485        assert!(issues.is_empty(), "{issues:?}");
486
487        let (block, issues) = exposure_block(&cfg("COPILOT_HTTP_BIND=not-an-ip\n"));
488        assert_eq!(issues, vec!["http_config_invalid"]);
489        assert!(block.get("ERROR").is_some());
490    }
491
492    #[test]
493    fn network_inspect_classification() {
494        assert_eq!(classify_network_inspect(true, ""), NetworkState::Exists);
495        assert_eq!(
496            classify_network_inspect(
497                false,
498                "Error response from daemon: network talon-agents not found"
499            ),
500            NetworkState::Missing
501        );
502        assert_eq!(
503            classify_network_inspect(false, "Error: No such network: talon-agents"),
504            NetworkState::Missing
505        );
506        assert_eq!(
507            classify_network_inspect(
508                false,
509                "Error: unable to find network with name or ID talon-agents"
510            ),
511            NetworkState::Missing
512        );
513        assert_eq!(
514            classify_network_inspect(false, "Cannot connect to the Docker daemon"),
515            NetworkState::Unknown
516        );
517    }
518
519    #[tokio::test]
520    async fn network_inspect_runs_the_runtime_binary() {
521        let tmp = tempfile::tempdir().unwrap();
522        let fake = tmp.path().join("fake-docker");
523        std::fs::write(
524            &fake,
525            "#!/bin/sh\n[ \"$1 $2 $3\" = \"network inspect talon-agents\" ] && exit 0\n\
526             echo \"Error: No such network: $3\" >&2\nexit 1\n",
527        )
528        .unwrap();
529        use std::os::unix::fs::PermissionsExt;
530        std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
531        let bin = fake.to_string_lossy();
532        assert_eq!(
533            inspect_network(&bin, "talon-agents").await,
534            Ok(NetworkState::Exists)
535        );
536        assert_eq!(
537            inspect_network(&bin, "other").await,
538            Ok(NetworkState::Missing)
539        );
540        assert!(inspect_network("/nonexistent/docker", "x").await.is_err());
541    }
542
543    #[test]
544    fn every_known_issue_has_a_specific_hint() {
545        for issue in [
546            "container_runtime_missing",
547            "image_missing",
548            "vllm_not_configured",
549            "vllm_auth_failed",
550            "gateway_bind_loopback",
551            "vllm_api_key_unset",
552            "agent_network_missing",
553            "agent_network_unchecked",
554            "http_api_public_bind",
555            "http_config_invalid",
556            "http_api_key_unset",
557            "audit_chain_tampered",
558        ] {
559            assert_ne!(hint(issue), hint("unknown_issue"), "{issue}");
560        }
561    }
562}