1use 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
28pub 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 does not verify (see AUDIT_VERIFY ERROR): investigate with `talon audit \
98 tail` before trusting it"
99 }
100 "audit_key_missing" => {
101 "the audit log has keyed rows but TALON_AUDIT_KEY is not set in .env: restore the key \
102 (`talon run` refuses to start without it)"
103 }
104 "audit_key_invalid" => {
105 "TALON_AUDIT_KEY is too short or not random (openssl rand -hex 32); `talon run` \
106 refuses to start"
107 }
108 _ => "see the blocks above",
109 }
110}
111
112fn issues_of(block: Option<&StatusBlock>) -> Vec<String> {
113 block
114 .and_then(|b| b.get("ISSUES"))
115 .filter(|v| *v != "none" && !v.is_empty())
116 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
117 .unwrap_or_default()
118}
119
120async fn gateway_block(config: &TalonConfig) -> (StatusBlock, Vec<String>) {
122 let mut issues = Vec::new();
123 let mut block = StatusBlock::new("CHECK_GATEWAY");
124 let explicit = config.env.get("TALON_LLM_GATEWAY_BIND").is_some();
125 block.set(
126 "BIND_SOURCE",
127 if explicit { "explicit" } else { "detected" },
128 );
129 let runtime = ContainerRuntime::detect(&config.container_runtime).ok();
130 let ip: Option<IpAddr> = match &runtime {
131 Some(rt) => match gateway_bind_ip(config, rt).await {
132 Ok(ip) => Some(ip),
133 Err(e) => {
134 block.set("ERROR", format!("{e:#}"));
135 issues.push("gateway_bind_unavailable".to_string());
136 None
137 }
138 },
139 None => config
140 .env
141 .get("TALON_LLM_GATEWAY_BIND")
142 .and_then(|v| v.trim().parse().ok())
143 .or(Some(IpAddr::V4(Ipv4Addr::LOCALHOST))),
144 };
145 if let Some(ip) = ip {
146 block.set("BIND", ip);
147 block.set("PORT", config.llm_gateway_port);
148 let port = check_port(&ip.to_string(), config.llm_gateway_port).await;
149 block.set("PORT_STATUS", port.as_str());
150 match port.as_str() {
151 "free" => {}
152 "in_use" => issues.push("gateway_port_in_use".to_string()),
153 _ => issues.push("gateway_bind_unavailable".to_string()),
154 }
155 let linux = runtime
156 .as_ref()
157 .is_some_and(|rt| rt.platform() == HostPlatform::Linux);
158 if linux && ip.is_loopback() {
159 issues.push("gateway_bind_loopback".to_string());
160 }
161 }
162 let host = config
163 .env
164 .get("TALON_LLM_GATEWAY_HOST")
165 .unwrap_or_else(|| DEFAULT_CONTAINER_HOST.to_string());
166 block.set(
167 "CONTAINER_URL",
168 format!("http://{host}:{}/v1", config.llm_gateway_port),
169 );
170 block.set(
171 "STATUS",
172 if issues.is_empty() {
173 "success"
174 } else {
175 "failed"
176 },
177 );
178 (block, issues)
179}
180
181pub fn exposure_block(config: &TalonConfig) -> (StatusBlock, Vec<String>) {
185 let mut issues = Vec::new();
186 let mut block = StatusBlock::new("CHECK_EXPOSURE");
187 let key = match api_key_policy(config) {
188 ApiKeyPolicy::Present => "set",
189 ApiKeyPolicy::MissingAllowed => "unset_allowed",
190 ApiKeyPolicy::Missing => {
191 if effective_vllm(&config.provider_defaults).is_some() {
192 issues.push("vllm_api_key_unset".to_string());
193 }
194 "unset"
195 }
196 };
197 block.set("VLLM_API_KEY", key);
198 match HttpConfig::from_talon_config(config) {
199 Ok(http) => {
200 block.set("HTTP_BIND", http.bind);
201 block.set("HTTP_PORT", http.port);
202 block.set("HTTP_TLS_TERMINATED", http.tls_terminated);
203 if http.bind.is_unspecified() {
204 issues.push("http_api_public_bind".to_string());
205 }
206 }
207 Err(e) => {
208 block.set("ERROR", e);
209 issues.push("http_config_invalid".to_string());
210 }
211 }
212 block.set(
213 "STATUS",
214 if issues.is_empty() {
215 "success"
216 } else {
217 "failed"
218 },
219 );
220 (block, issues)
221}
222
223const NETWORK_INSPECT_TIMEOUT: Duration = Duration::from_secs(20);
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum NetworkState {
229 Exists,
231 Missing,
233 Unknown,
235}
236
237pub fn classify_network_inspect(success: bool, stderr: &str) -> NetworkState {
239 if success {
240 return NetworkState::Exists;
241 }
242 let err = stderr.to_ascii_lowercase();
243 if err.contains("no such network")
244 || err.contains("not found")
245 || err.contains("unable to find network")
246 {
247 NetworkState::Missing
248 } else {
249 NetworkState::Unknown
250 }
251}
252
253async fn inspect_network(bin: &str, name: &str) -> Result<NetworkState, String> {
254 let child = tokio::process::Command::new(bin)
255 .args(network_inspect_args(name))
256 .stdin(Stdio::null())
257 .stdout(Stdio::null())
258 .stderr(Stdio::piped())
259 .kill_on_drop(true)
260 .output();
261 match tokio::time::timeout(NETWORK_INSPECT_TIMEOUT, child).await {
262 Ok(Ok(out)) => Ok(classify_network_inspect(
263 out.status.success(),
264 &String::from_utf8_lossy(&out.stderr),
265 )),
266 Ok(Err(e)) => Err(format!("cannot run {bin}: {e}")),
267 Err(_) => Err(format!("{bin} network inspect timed out")),
268 }
269}
270
271async fn network_block(config: &TalonConfig) -> Option<(StatusBlock, Vec<String>)> {
275 let runtime = ContainerRuntime::detect(&config.container_runtime).ok()?;
276 let mut block = StatusBlock::new("CHECK_NETWORK");
277 let mut issues = Vec::new();
278 match RunnerConfig::from_talon_config(config).default_network {
279 None => {
280 block.set("NETWORK", "runtime-default");
281 block.set(
282 "NOTE",
283 "TALON_CONTAINER_NETWORK selects the runtime default bridge: containers can reach \
284 each other and `talon setup egress` rules do not apply",
285 );
286 }
287 Some(network) => {
288 block.set("NETWORK", &network);
289 match inspect_network(runtime.bin(), &network).await {
290 Ok(NetworkState::Exists) => block.set("EXISTS", "yes"),
291 Ok(NetworkState::Missing) => {
292 block.set("EXISTS", "no");
293 issues.push("agent_network_missing".to_string());
294 }
295 Ok(NetworkState::Unknown) => {
296 block.set("EXISTS", "unknown");
297 issues.push("agent_network_unchecked".to_string());
298 }
299 Err(e) => {
300 block.set("EXISTS", "unknown");
301 block.set("ERROR", e);
302 issues.push("agent_network_unchecked".to_string());
303 }
304 }
305 }
306 }
307 block.set(
308 "STATUS",
309 if issues.is_empty() {
310 "success"
311 } else {
312 "failed"
313 },
314 );
315 Some((block, issues))
316}
317
318fn audit_block(config: &TalonConfig) -> Option<(StatusBlock, Vec<String>)> {
320 let path = default_db_path(&config.store_dir);
321 if !path.exists() {
322 return None;
323 }
324 let db = match Db::open_with_options(
325 &path,
326 &DbOptions {
327 assistant_name: config.assistant_name.clone(),
328 },
329 ) {
330 Ok(db) => db,
331 Err(e) => {
332 return Some((
333 StatusBlock::new("AUDIT_VERIFY")
334 .field("STATUS", "failed")
335 .field("ERROR", e),
336 vec!["audit_chain_tampered".to_string()],
337 ));
338 }
339 };
340 if let Err(e) = crate::audit_cmd::enable_audit_key(&db, config) {
341 return Some((
342 StatusBlock::new("AUDIT_VERIFY")
343 .field("STATUS", "failed")
344 .field("ERROR", format!("{e:#}")),
345 vec!["audit_key_invalid".to_string()],
346 ));
347 }
348 match crate::audit_cmd::verify_block(&db) {
349 Ok((block, true)) => Some((block, Vec::new())),
352 Ok((block, false)) => {
353 let issue = if block.get("CHAIN") == Some("unverifiable") {
354 "audit_key_missing"
355 } else {
356 "audit_chain_tampered"
357 };
358 Some((block, vec![issue.to_string()]))
359 }
360 Err(e) => Some((
361 StatusBlock::new("AUDIT_VERIFY")
362 .field("STATUS", "failed")
363 .field("ERROR", format!("{e:#}")),
364 Vec::new(),
365 )),
366 }
367}
368
369pub fn summary_block(issues: &[String]) -> StatusBlock {
371 let mut block = StatusBlock::new("DOCTOR").field("VERSION", crate::VERSION);
372 if issues.is_empty() {
373 block.set("ISSUES", "none");
374 block.set("STATUS", "success");
375 return block;
376 }
377 block.set("ISSUES", issues.join(","));
378 for (i, issue) in issues.iter().enumerate() {
379 block.set(
380 &format!("HINT_{}", i + 1),
381 format!("{issue}: {}", hint(issue)),
382 );
383 }
384 block.set("STATUS", "failed");
385 block
386}
387
388pub async fn run(project_root: &Path, config: &TalonConfig) -> i32 {
390 let ctx = SetupContext::detect(project_root);
391 let no_args = StepArgs::new(Vec::<String>::new());
392 let mut issues: Vec<String> = Vec::new();
393
394 let env = run_step(Step::Environment, &no_args, &ctx).await;
395 println!("{}", env.render());
396 issues.extend(issues_of(env.block.as_ref()));
397
398 let vllm = run_step(Step::Vllm, &no_args, &ctx).await;
399 println!("{}", vllm.render());
400
401 let (gateway, gw_issues) = gateway_block(config).await;
402 println!("{gateway}");
403 issues.retain(|i| !i.starts_with("gateway_port_"));
406 issues.extend(gw_issues);
407
408 let (exposure, exposure_issues) = exposure_block(config);
409 println!("{exposure}");
410 issues.extend(exposure_issues);
411
412 if let Some((network, network_issues)) = network_block(config).await {
413 println!("{network}");
414 issues.extend(network_issues);
415 }
416
417 if config.env.file_value("HTTP_API_KEY").is_none() {
418 issues.push("http_api_key_unset".to_string());
419 }
420
421 if let Some((block, audit_issues)) = audit_block(config) {
422 println!("{block}");
423 issues.extend(audit_issues);
424 }
425
426 let mut seen = std::collections::HashSet::new();
427 issues.retain(|i| seen.insert(i.clone()));
428 println!("{}", summary_block(&issues));
429 if issues.is_empty() { 0 } else { 1 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn issue_parsing_and_summary() {
438 let block = StatusBlock::new("CHECK_ENVIRONMENT")
439 .field("ISSUES", "image_missing,vllm_not_configured");
440 let issues = issues_of(Some(&block));
441 assert_eq!(issues, vec!["image_missing", "vllm_not_configured"]);
442 assert!(issues_of(Some(&StatusBlock::new("X").field("ISSUES", "none"))).is_empty());
443 assert!(issues_of(None).is_empty());
444
445 let summary = summary_block(&issues);
446 assert_eq!(summary.get("STATUS"), Some("failed"));
447 assert!(
448 summary
449 .get("HINT_2")
450 .unwrap()
451 .contains("TALON_VLLM_BASE_URL")
452 );
453 assert_eq!(summary_block(&[]).get("STATUS"), Some("success"));
454 }
455
456 fn cfg(content: &str) -> TalonConfig {
457 TalonConfig::from_env(
458 Path::new("/tmp/talon-doctor-test"),
459 talon_core::env::EnvFile::from_str_content(content),
460 )
461 }
462
463 #[test]
464 fn audit_block_reports_unkeyed_hint_and_key_problems() {
465 const KEY: &str = "7a9c1e3b5d7f9a0c2e4b6d8f0a1c3e5b7d9f1a3c5e7b9d0f2a4c6e8b0d1f3a5c";
466 let dir = tempfile::tempdir().unwrap();
467 let config = |env: &str| {
468 TalonConfig::from_env(dir.path(), talon_core::env::EnvFile::from_str_content(env))
469 };
470 let record = |config: &TalonConfig| {
471 let db = Db::open(&default_db_path(&config.store_dir)).unwrap();
472 crate::audit_cmd::enable_audit_key(&db, config).unwrap();
473 db.record_audit(&talon_core::audit::event(
474 "talon.start",
475 "talon",
476 None,
477 "info",
478 serde_json::json!({}),
479 ))
480 .unwrap();
481 };
482 assert!(audit_block(&config("")).is_none());
484
485 record(&config(""));
487 let (block, issues) = audit_block(&config("")).unwrap();
488 assert!(issues.is_empty(), "{issues:?}");
489 assert_eq!(block.get("STATUS"), Some("success"));
490 assert!(block.get("HINT").unwrap().contains("TALON_AUDIT_KEY"));
491
492 let (block, issues) = audit_block(&config("TALON_AUDIT_KEY=tooshort-pw\n")).unwrap();
494 assert_eq!(issues, vec!["audit_key_invalid"]);
495 assert!(!format!("{block}").contains("tooshort-pw"));
496 assert!(hint("audit_key_invalid").contains("openssl rand -hex 32"));
497
498 let keyed = config(&format!("TALON_AUDIT_KEY={KEY}\n"));
500 record(&keyed);
501 let (block, issues) = audit_block(&keyed).unwrap();
502 assert!(issues.is_empty(), "{block}");
503 assert_eq!(block.get("HINT"), None);
504 assert_eq!(block.get("CHECKPOINT"), Some("verified"));
505 assert!(!format!("{block}").contains(KEY));
506 let (block, issues) = audit_block(&config("")).unwrap();
507 assert_eq!(issues, vec!["audit_key_missing"]);
508 assert_eq!(block.get("CHAIN"), Some("unverifiable"));
509 assert!(hint("audit_key_missing").contains("TALON_AUDIT_KEY"));
510 }
511
512 fn env_overridden(keys: &[&str]) -> bool {
514 keys.iter().any(|k| std::env::var(k).is_ok())
515 }
516
517 #[test]
518 fn exposure_flags_missing_vllm_key_and_public_http_bind() {
519 if env_overridden(&[
520 "TALON_VLLM_BASE_URL",
521 "TALON_VLLM_MODEL",
522 "TALON_VLLM_ALLOW_NO_API_KEY",
523 "COPILOT_HTTP_BIND",
524 ]) {
525 return;
526 }
527 const VLLM: &str = "TALON_VLLM_BASE_URL=http://vllm:8000\nTALON_VLLM_MODEL=q\n";
528
529 let (block, issues) = exposure_block(&cfg(&format!("{VLLM}COPILOT_HTTP_BIND=0.0.0.0\n")));
530 assert_eq!(issues, vec!["vllm_api_key_unset", "http_api_public_bind"]);
531 assert_eq!(block.get("VLLM_API_KEY"), Some("unset"));
532 assert_eq!(block.get("STATUS"), Some("failed"));
533 let summary = summary_block(&issues);
534 assert!(
535 summary
536 .get("HINT_1")
537 .unwrap()
538 .contains("TALON_VLLM_API_KEY")
539 );
540 assert!(summary.get("HINT_2").unwrap().contains("127.0.0.1"));
541
542 let (_, issues) = exposure_block(&cfg(&format!("{VLLM}COPILOT_HTTP_BIND=::\n")));
543 assert!(issues.contains(&"http_api_public_bind".to_string()));
544
545 let (block, issues) = exposure_block(&cfg(&format!(
546 "{VLLM}TALON_VLLM_API_KEY=k\nCOPILOT_HTTP_BIND=127.0.0.1\n"
547 )));
548 assert!(issues.is_empty(), "{issues:?}");
549 assert_eq!(block.get("VLLM_API_KEY"), Some("set"));
550 assert_eq!(block.get("STATUS"), Some("success"));
551
552 let (block, issues) = exposure_block(&cfg(&format!(
553 "{VLLM}TALON_VLLM_ALLOW_NO_API_KEY=true\nCOPILOT_HTTP_BIND=10.0.0.5\n"
554 )));
555 assert!(issues.is_empty(), "{issues:?}");
556 assert_eq!(block.get("VLLM_API_KEY"), Some("unset_allowed"));
557
558 let (_, issues) = exposure_block(&cfg("COPILOT_HTTP_BIND=127.0.0.1\n"));
560 assert!(issues.is_empty(), "{issues:?}");
561
562 let (block, issues) = exposure_block(&cfg("COPILOT_HTTP_BIND=not-an-ip\n"));
563 assert_eq!(issues, vec!["http_config_invalid"]);
564 assert!(block.get("ERROR").is_some());
565 }
566
567 #[test]
568 fn network_inspect_classification() {
569 assert_eq!(classify_network_inspect(true, ""), NetworkState::Exists);
570 assert_eq!(
571 classify_network_inspect(
572 false,
573 "Error response from daemon: network talon-agents not found"
574 ),
575 NetworkState::Missing
576 );
577 assert_eq!(
578 classify_network_inspect(false, "Error: No such network: talon-agents"),
579 NetworkState::Missing
580 );
581 assert_eq!(
582 classify_network_inspect(
583 false,
584 "Error: unable to find network with name or ID talon-agents"
585 ),
586 NetworkState::Missing
587 );
588 assert_eq!(
589 classify_network_inspect(false, "Cannot connect to the Docker daemon"),
590 NetworkState::Unknown
591 );
592 }
593
594 #[tokio::test]
595 async fn network_inspect_runs_the_runtime_binary() {
596 let tmp = tempfile::tempdir().unwrap();
597 let fake = tmp.path().join("fake-docker");
598 std::fs::write(
599 &fake,
600 "#!/bin/sh\n[ \"$1 $2 $3\" = \"network inspect talon-agents\" ] && exit 0\n\
601 echo \"Error: No such network: $3\" >&2\nexit 1\n",
602 )
603 .unwrap();
604 use std::os::unix::fs::PermissionsExt;
605 std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
606 let bin = fake.to_string_lossy();
607 assert_eq!(
608 inspect_network(&bin, "talon-agents").await,
609 Ok(NetworkState::Exists)
610 );
611 assert_eq!(
612 inspect_network(&bin, "other").await,
613 Ok(NetworkState::Missing)
614 );
615 assert!(inspect_network("/nonexistent/docker", "x").await.is_err());
616 }
617
618 #[test]
619 fn every_known_issue_has_a_specific_hint() {
620 for issue in [
621 "container_runtime_missing",
622 "image_missing",
623 "vllm_not_configured",
624 "vllm_auth_failed",
625 "gateway_bind_loopback",
626 "vllm_api_key_unset",
627 "agent_network_missing",
628 "agent_network_unchecked",
629 "http_api_public_bind",
630 "http_config_invalid",
631 "http_api_key_unset",
632 "audit_chain_tampered",
633 ] {
634 assert_ne!(hint(issue), hint("unknown_issue"), "{issue}");
635 }
636 }
637}