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 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
111async 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
172pub 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
214const NETWORK_INSPECT_TIMEOUT: Duration = Duration::from_secs(20);
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum NetworkState {
220 Exists,
222 Missing,
224 Unknown,
226}
227
228pub 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
262async 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
309fn 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
343pub 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
362pub 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 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 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 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}