1use std::net::{IpAddr, Ipv4Addr};
16use std::path::Path;
17use std::process::Stdio;
18use std::time::Duration;
19
20use talon_container::runtime::network_inspect_args;
21use talon_container::{ContainerRuntime, HostPlatform, RunnerConfig};
22use talon_core::config::TalonConfig;
23use talon_core::provider::effective_vllm;
24use talon_db::{Db, DbOptions, default_db_path};
25use talon_http::HttpConfig;
26use talon_security::instructions::InstructionFiles;
27use talon_setup::checks::check_port;
28use talon_setup::{SetupContext, StatusBlock, Step, StepArgs, run_step};
29
30use crate::app::{ApiKeyPolicy, DEFAULT_CONTAINER_HOST, api_key_policy, gateway_bind_ip};
31
32pub fn hint(issue: &str) -> &'static str {
34 match issue {
35 "container_runtime_missing" => "install docker or podman (CONTAINER_RUNTIME selects one)",
36 "container_runtime_not_running" => {
37 "start the container daemon (`docker info` must succeed)"
38 }
39 "configured_runtime_unavailable" => {
40 "CONTAINER_RUNTIME is not installed; another runtime was found and will be used"
41 }
42 "image_missing" => {
43 "build the agent image: ./container/build.sh (or `talahon setup container`)"
44 }
45 "image_has_old_name" => {
46 "an image with the pre-rename name exists (CHECK_ENVIRONMENT LEGACY_IMAGE) but is \
47 not used: the agent image is now called talahon-agent. Rebuild it with \
48 ./container/build.sh; to keep running the old image on purpose set \
49 CONTAINER_IMAGE to its name"
50 }
51 "vllm_not_configured" => {
52 "set TALAHON_VLLM_BASE_URL and TALAHON_VLLM_MODEL in .env (see .env.example); without them \
53 the LLM gateway does not start and every agent run fails"
54 }
55 "vllm_invalid_url" => "TALAHON_VLLM_BASE_URL must be an http(s) URL, e.g. http://vllm:8000",
56 "vllm_unreachable" => {
57 "vLLM is not reachable from this host (network, firewall, server down)"
58 }
59 "vllm_auth_failed" => {
60 "vLLM rejected the key: set TALAHON_VLLM_API_KEY in .env to vLLM's --api-key"
61 }
62 "vllm_api_key_unset" => {
63 "TALAHON_VLLM_API_KEY is not set, so the LLM gateway refuses to start (without a key \
64 containers could call vLLM directly and bypass the gateway): start vLLM with \
65 --api-key and put the same value in .env; only if containers provably cannot reach \
66 vLLM, set TALAHON_VLLM_ALLOW_NO_API_KEY=true"
67 }
68 "vllm_model_missing" => {
69 "TALAHON_VLLM_MODEL is not served by vLLM (compare with --served-model-name / MODELS)"
70 }
71 "vllm_http_error" | "vllm_invalid_response" => {
72 "vLLM answered /v1/models unexpectedly; check TALAHON_VLLM_BASE_URL points at vLLM"
73 }
74 "gateway_port_in_use" => {
75 "TALAHON_LLM_GATEWAY_PORT is in use (expected while talahon is running; otherwise pick another port)"
76 }
77 "gateway_port_unavailable" | "gateway_bind_unavailable" => {
78 "the LLM gateway cannot bind its address: set TALAHON_LLM_GATEWAY_BIND to a local address"
79 }
80 "gateway_bind_loopback" => {
81 "on Linux, containers cannot reach a gateway bound to 127.0.0.1: set \
82 TALAHON_LLM_GATEWAY_BIND to the docker bridge address (e.g. 172.17.0.1)"
83 }
84 "agent_network_missing" => {
85 "the agent container network (talon-agents) does not exist: run `talahon setup egress` \
86 (creates it with inter-container traffic disabled plus the egress firewall rules); \
87 `talahon run` also creates the network at startup, but not the firewall rules"
88 }
89 "agent_network_unchecked" => {
90 "the agent container network could not be inspected (is the container runtime running?)"
91 }
92 "dirs_not_writable" => {
93 "store/, data/, groups/ and logs/ must be writable by the service user"
94 }
95 "low_disk_space" => "free some disk space (images, logs, data/)",
96 "http_api_public_bind" => {
97 "the CoPilot HTTP API listens on every interface (COPILOT_HTTP_BIND=0.0.0.0 / ::): \
98 bind it to 127.0.0.1 or the private address CoPilot uses, and firewall the port"
99 }
100 "http_config_invalid" => {
101 "a COPILOT_HTTP_* setting is invalid (see CHECK_EXPOSURE ERROR); `talahon run` refuses to start"
102 }
103 "http_api_key_unset" => {
104 "HTTP_API_KEY is not set: the CoPilot HTTP API rejects every request (openssl rand -hex 32)"
105 }
106 "audit_chain_tampered" => {
107 "the audit log does not verify (see AUDIT_VERIFY ERROR): investigate with `talahon audit \
108 tail` before trusting it"
109 }
110 "audit_key_missing" => {
111 "the audit log has keyed rows but TALAHON_AUDIT_KEY is not set in .env: restore the key \
112 (`talahon run` refuses to start without it)"
113 }
114 "audit_key_invalid" => {
115 "TALAHON_AUDIT_KEY is too short or not random (openssl rand -hex 32); `talahon run` \
116 refuses to start"
117 }
118 "instructions_conflict" => {
119 "a group folder has both AGENT.md and CLAUDE.md with content (see CHECK_INSTRUCTIONS \
120 CONFLICT): every run in that folder is refused, because the file is the trusted \
121 system prompt and older versions did not protect the name AGENT.md. Inspect both \
122 files, keep the one you wrote and delete the other (keep exactly one; rename a kept \
123 CLAUDE.md to AGENT.md)"
124 }
125 "instructions_unsafe" => {
126 "AGENT.md or CLAUDE.md of a group folder is a symlink, directory or other non-regular \
127 file (see CHECK_INSTRUCTIONS UNSAFE): every run in that folder is refused. Replace \
128 it with a regular file, or remove it"
129 }
130 _ => "see the blocks above",
131 }
132}
133
134fn issues_of(block: Option<&StatusBlock>) -> Vec<String> {
135 block
136 .and_then(|b| b.get("ISSUES"))
137 .filter(|v| *v != "none" && !v.is_empty())
138 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
139 .unwrap_or_default()
140}
141
142async fn gateway_block(config: &TalonConfig) -> (StatusBlock, Vec<String>) {
144 let mut issues = Vec::new();
145 let mut block = StatusBlock::new("CHECK_GATEWAY");
146 let explicit = config.env.get("TALAHON_LLM_GATEWAY_BIND").is_some();
147 block.set(
148 "BIND_SOURCE",
149 if explicit { "explicit" } else { "detected" },
150 );
151 let runtime = ContainerRuntime::detect(&config.container_runtime).ok();
152 let ip: Option<IpAddr> = match &runtime {
153 Some(rt) => match gateway_bind_ip(config, rt).await {
154 Ok(ip) => Some(ip),
155 Err(e) => {
156 block.set("ERROR", format!("{e:#}"));
157 issues.push("gateway_bind_unavailable".to_string());
158 None
159 }
160 },
161 None => config
162 .env
163 .get("TALAHON_LLM_GATEWAY_BIND")
164 .and_then(|v| v.trim().parse().ok())
165 .or(Some(IpAddr::V4(Ipv4Addr::LOCALHOST))),
166 };
167 if let Some(ip) = ip {
168 block.set("BIND", ip);
169 block.set("PORT", config.llm_gateway_port);
170 let port = check_port(&ip.to_string(), config.llm_gateway_port).await;
171 block.set("PORT_STATUS", port.as_str());
172 match port.as_str() {
173 "free" => {}
174 "in_use" => issues.push("gateway_port_in_use".to_string()),
175 _ => issues.push("gateway_bind_unavailable".to_string()),
176 }
177 let linux = runtime
178 .as_ref()
179 .is_some_and(|rt| rt.platform() == HostPlatform::Linux);
180 if linux && ip.is_loopback() {
181 issues.push("gateway_bind_loopback".to_string());
182 }
183 }
184 let host = config
185 .env
186 .get("TALAHON_LLM_GATEWAY_HOST")
187 .unwrap_or_else(|| DEFAULT_CONTAINER_HOST.to_string());
188 block.set(
189 "CONTAINER_URL",
190 format!("http://{host}:{}/v1", config.llm_gateway_port),
191 );
192 block.set(
193 "STATUS",
194 if issues.is_empty() {
195 "success"
196 } else {
197 "failed"
198 },
199 );
200 (block, issues)
201}
202
203pub fn exposure_block(config: &TalonConfig) -> (StatusBlock, Vec<String>) {
207 let mut issues = Vec::new();
208 let mut block = StatusBlock::new("CHECK_EXPOSURE");
209 let key = match api_key_policy(config) {
210 ApiKeyPolicy::Present => "set",
211 ApiKeyPolicy::MissingAllowed => "unset_allowed",
212 ApiKeyPolicy::Missing => {
213 if effective_vllm(&config.provider_defaults).is_some() {
214 issues.push("vllm_api_key_unset".to_string());
215 }
216 "unset"
217 }
218 };
219 block.set("VLLM_API_KEY", key);
220 match HttpConfig::from_talon_config(config) {
221 Ok(http) => {
222 block.set("HTTP_BIND", http.bind);
223 block.set("HTTP_PORT", http.port);
224 block.set("HTTP_TLS_TERMINATED", http.tls_terminated);
225 if http.bind.is_unspecified() {
226 issues.push("http_api_public_bind".to_string());
227 }
228 }
229 Err(e) => {
230 block.set("ERROR", e);
231 issues.push("http_config_invalid".to_string());
232 }
233 }
234 block.set(
235 "STATUS",
236 if issues.is_empty() {
237 "success"
238 } else {
239 "failed"
240 },
241 );
242 (block, issues)
243}
244
245const NETWORK_INSPECT_TIMEOUT: Duration = Duration::from_secs(20);
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum NetworkState {
251 Exists,
253 Missing,
255 Unknown,
257}
258
259pub fn classify_network_inspect(success: bool, stderr: &str) -> NetworkState {
261 if success {
262 return NetworkState::Exists;
263 }
264 let err = stderr.to_ascii_lowercase();
265 if err.contains("no such network")
266 || err.contains("not found")
267 || err.contains("unable to find network")
268 {
269 NetworkState::Missing
270 } else {
271 NetworkState::Unknown
272 }
273}
274
275async fn inspect_network(bin: &str, name: &str) -> Result<NetworkState, String> {
276 let child = tokio::process::Command::new(bin)
277 .args(network_inspect_args(name))
278 .stdin(Stdio::null())
279 .stdout(Stdio::null())
280 .stderr(Stdio::piped())
281 .kill_on_drop(true)
282 .output();
283 match tokio::time::timeout(NETWORK_INSPECT_TIMEOUT, child).await {
284 Ok(Ok(out)) => Ok(classify_network_inspect(
285 out.status.success(),
286 &String::from_utf8_lossy(&out.stderr),
287 )),
288 Ok(Err(e)) => Err(format!("cannot run {bin}: {e}")),
289 Err(_) => Err(format!("{bin} network inspect timed out")),
290 }
291}
292
293async fn network_block(config: &TalonConfig) -> Option<(StatusBlock, Vec<String>)> {
297 let runtime = ContainerRuntime::detect(&config.container_runtime).ok()?;
298 let mut block = StatusBlock::new("CHECK_NETWORK");
299 let mut issues = Vec::new();
300 match RunnerConfig::from_talon_config(config).default_network {
301 None => {
302 block.set("NETWORK", "runtime-default");
303 block.set(
304 "NOTE",
305 "TALAHON_CONTAINER_NETWORK selects the runtime default bridge: containers can reach \
306 each other and `talahon setup egress` rules do not apply",
307 );
308 }
309 Some(network) => {
310 block.set("NETWORK", &network);
311 match inspect_network(runtime.bin(), &network).await {
312 Ok(NetworkState::Exists) => block.set("EXISTS", "yes"),
313 Ok(NetworkState::Missing) => {
314 block.set("EXISTS", "no");
315 issues.push("agent_network_missing".to_string());
316 }
317 Ok(NetworkState::Unknown) => {
318 block.set("EXISTS", "unknown");
319 issues.push("agent_network_unchecked".to_string());
320 }
321 Err(e) => {
322 block.set("EXISTS", "unknown");
323 block.set("ERROR", e);
324 issues.push("agent_network_unchecked".to_string());
325 }
326 }
327 }
328 }
329 block.set(
330 "STATUS",
331 if issues.is_empty() {
332 "success"
333 } else {
334 "failed"
335 },
336 );
337 Some((block, issues))
338}
339
340const MAX_LISTED_FOLDERS: usize = 50;
342
343fn listed(names: &[String]) -> String {
344 if names.is_empty() {
345 return "none".to_string();
346 }
347 let mut out = names
348 .iter()
349 .take(MAX_LISTED_FOLDERS)
350 .map(String::as_str)
351 .collect::<Vec<_>>()
352 .join(",");
353 if names.len() > MAX_LISTED_FOLDERS {
354 out.push_str(&format!(",... ({} more)", names.len() - MAX_LISTED_FOLDERS));
355 }
356 out
357}
358
359pub fn instructions_block(config: &TalonConfig) -> (StatusBlock, Vec<String>) {
371 let mut block = StatusBlock::new("CHECK_INSTRUCTIONS");
372 let mut issues = Vec::new();
373 let mut folders: Vec<String> = std::fs::read_dir(&config.groups_dir)
374 .map(|entries| {
375 entries
376 .filter_map(Result::ok)
377 .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
378 .filter_map(|e| e.file_name().into_string().ok())
379 .collect()
380 })
381 .unwrap_or_default();
382 folders.sort();
383 let (mut legacy, mut conflicts, mut unsafe_entries) = (Vec::new(), Vec::new(), Vec::new());
384 for folder in &folders {
385 let files = match InstructionFiles::inspect(&config.groups_dir.join(folder)) {
386 Ok(files) => files,
387 Err(e) => {
388 unsafe_entries.push(format!("{folder} (cannot be inspected: {e})"));
389 continue;
390 }
391 };
392 if let Some(found) = files.unsafe_entry() {
393 unsafe_entries.push(format!("{folder} ({found})"));
394 } else if files.is_conflict() {
395 conflicts.push(folder.clone());
396 } else if files.uses_legacy() {
397 legacy.push(folder.clone());
398 }
399 }
400 block.set("FOLDERS", folders.len());
401 block.set("LEGACY", listed(&legacy));
402 block.set("CONFLICT", listed(&conflicts));
403 block.set("UNSAFE", listed(&unsafe_entries));
404 if !legacy.is_empty() {
405 block.set(
406 "HINT",
407 "the LEGACY folders still keep their instructions in CLAUDE.md, which is read as \
408 before. Rename it when convenient: `mv groups/<folder>/CLAUDE.md \
409 groups/<folder>/AGENT.md` (this replaces the AGENT.md mount-point stub Talahon put \
410 there; use `git mv -f` for tracked files)",
411 );
412 }
413 if !conflicts.is_empty() {
414 issues.push("instructions_conflict".to_string());
415 }
416 if !unsafe_entries.is_empty() {
417 issues.push("instructions_unsafe".to_string());
418 }
419 block.set(
420 "STATUS",
421 if issues.is_empty() {
422 "success"
423 } else {
424 "failed"
425 },
426 );
427 (block, issues)
428}
429
430pub fn rename_block(
442 keys: &[talon_core::env::LegacyKey],
443 config_dir: &talon_core::paths::ConfigDir,
444) -> StatusBlock {
445 use talon_core::env::EnvSource;
446 let mut block = StatusBlock::new("CHECK_RENAME");
447 let describe = |k: &talon_core::env::LegacyKey| {
448 let source = match k.source {
449 EnvSource::File => ".env",
450 EnvSource::Process => "environment",
451 };
452 format!("{} -> {} ({source})", k.legacy, k.current)
453 };
454 let deprecated: Vec<String> = keys.iter().map(describe).collect();
455 let conflicts: Vec<String> = keys.iter().filter(|k| k.conflict).map(describe).collect();
456 block.set("DEPRECATED_KEYS", listed(&deprecated));
457 block.set("CONFLICTING_KEYS", listed(&conflicts));
458 block.set("CONFIG_DIR", config_dir.path.display());
459 block.set(
460 "CONFIG_DIR_NAME",
461 if config_dir.legacy {
462 "legacy"
463 } else {
464 "current"
465 },
466 );
467 if let Some(ignored) = &config_dir.ignored_legacy {
468 block.set("IGNORED_CONFIG_DIR", ignored.display());
469 }
470 let mut hints: Vec<String> = Vec::new();
471 if !deprecated.is_empty() {
472 hints.push(
473 "rename the DEPRECATED_KEYS (TALON_* -> TALAHON_*); the old spellings are read \
474 until the next major version"
475 .into(),
476 );
477 }
478 if !conflicts.is_empty() {
479 hints.push(
480 "CONFLICTING_KEYS are set under both names with different values: the TALAHON_* \
481 value is used, the TALON_* value is ignored; delete the old key"
482 .into(),
483 );
484 }
485 if config_dir.legacy {
486 hints.push(format!(
487 "the mount and sender allowlists are read from {} (pre-rename name); rename the \
488 directory to ~/.config/{} when convenient",
489 config_dir.path.display(),
490 talon_core::paths::CONFIG_DIR_NAME
491 ));
492 }
493 if let Some(ignored) = &config_dir.ignored_legacy {
494 hints.push(format!(
495 "{} is NOT read (only CONFIG_DIR is; the two are never merged): move anything \
496 still needed from it, then delete it",
497 ignored.display()
498 ));
499 }
500 if !hints.is_empty() {
501 block.set("HINT", hints.join("; "));
502 }
503 block.set("STATUS", "success");
504 block
505}
506
507fn audit_block(config: &TalonConfig) -> Option<(StatusBlock, Vec<String>)> {
509 let path = default_db_path(&config.store_dir);
510 if !path.exists() {
511 return None;
512 }
513 let db = match Db::open_with_options(
514 &path,
515 &DbOptions {
516 assistant_name: config.assistant_name.clone(),
517 },
518 ) {
519 Ok(db) => db,
520 Err(e) => {
521 return Some((
522 StatusBlock::new("AUDIT_VERIFY")
523 .field("STATUS", "failed")
524 .field("ERROR", e),
525 vec!["audit_chain_tampered".to_string()],
526 ));
527 }
528 };
529 if let Err(e) = crate::audit_cmd::enable_audit_key(&db, config) {
530 return Some((
531 StatusBlock::new("AUDIT_VERIFY")
532 .field("STATUS", "failed")
533 .field("ERROR", format!("{e:#}")),
534 vec!["audit_key_invalid".to_string()],
535 ));
536 }
537 match crate::audit_cmd::verify_block(&db) {
538 Ok((block, true)) => Some((block, Vec::new())),
541 Ok((block, false)) => {
542 let issue = if block.get("CHAIN") == Some("unverifiable") {
543 "audit_key_missing"
544 } else {
545 "audit_chain_tampered"
546 };
547 Some((block, vec![issue.to_string()]))
548 }
549 Err(e) => Some((
550 StatusBlock::new("AUDIT_VERIFY")
551 .field("STATUS", "failed")
552 .field("ERROR", format!("{e:#}")),
553 Vec::new(),
554 )),
555 }
556}
557
558pub fn summary_block(issues: &[String]) -> StatusBlock {
560 let mut block = StatusBlock::new("DOCTOR").field("VERSION", crate::VERSION);
561 if issues.is_empty() {
562 block.set("ISSUES", "none");
563 block.set("STATUS", "success");
564 return block;
565 }
566 block.set("ISSUES", issues.join(","));
567 for (i, issue) in issues.iter().enumerate() {
568 block.set(
569 &format!("HINT_{}", i + 1),
570 format!("{issue}: {}", hint(issue)),
571 );
572 }
573 block.set("STATUS", "failed");
574 block
575}
576
577pub async fn run(project_root: &Path, config: &TalonConfig) -> i32 {
579 let ctx = SetupContext::detect(project_root);
580 let no_args = StepArgs::new(Vec::<String>::new());
581 let mut issues: Vec<String> = Vec::new();
582
583 let env = run_step(Step::Environment, &no_args, &ctx).await;
584 println!("{}", env.render());
585 issues.extend(issues_of(env.block.as_ref()));
586
587 let vllm = run_step(Step::Vllm, &no_args, &ctx).await;
588 println!("{}", vllm.render());
589
590 let (gateway, gw_issues) = gateway_block(config).await;
591 println!("{gateway}");
592 issues.retain(|i| !i.starts_with("gateway_port_"));
595 issues.extend(gw_issues);
596
597 let (exposure, exposure_issues) = exposure_block(config);
598 println!("{exposure}");
599 issues.extend(exposure_issues);
600
601 if let Some((network, network_issues)) = network_block(config).await {
602 println!("{network}");
603 issues.extend(network_issues);
604 }
605
606 if config.env.file_value("HTTP_API_KEY").is_none() {
607 issues.push("http_api_key_unset".to_string());
608 }
609
610 let (instructions, instruction_issues) = instructions_block(config);
611 println!("{instructions}");
612 issues.extend(instruction_issues);
613
614 println!(
615 "{}",
616 rename_block(
617 &config.env.legacy_keys(),
618 &talon_core::paths::resolve_config_dir(
620 config
621 .config_dir
622 .parent()
623 .and_then(Path::parent)
624 .unwrap_or(&ctx.home_dir),
625 ),
626 )
627 );
628
629 if let Some((block, audit_issues)) = audit_block(config) {
630 println!("{block}");
631 issues.extend(audit_issues);
632 }
633
634 let mut seen = std::collections::HashSet::new();
635 issues.retain(|i| seen.insert(i.clone()));
636 println!("{}", summary_block(&issues));
637 if issues.is_empty() { 0 } else { 1 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
645 fn rename_block_lists_deprecated_keys_conflicts_and_the_config_dir() {
646 use std::collections::HashMap;
647 use talon_core::env::EnvFile;
648 use talon_core::paths::resolve_config_dir;
649
650 let home = tempfile::tempdir().unwrap();
651 let clean = rename_block(
653 &EnvFile::from_str_content("TALAHON_VLLM_MODEL=q\n").legacy_keys_in(&HashMap::new()),
654 &resolve_config_dir(home.path()),
655 );
656 assert_eq!(clean.get("DEPRECATED_KEYS"), Some("none"));
657 assert_eq!(clean.get("CONFLICTING_KEYS"), Some("none"));
658 assert_eq!(clean.get("CONFIG_DIR_NAME"), Some("current"));
659 assert_eq!(clean.get("HINT"), None);
660 assert_eq!(clean.get("STATUS"), Some("success"));
661
662 let old_dir = home.path().join(".config/talon");
665 std::fs::create_dir_all(&old_dir).unwrap();
666 let env = EnvFile::from_str_content(
667 "TALON_VLLM_BASE_URL=http://old:8000\nTALON_VLLM_MODEL=old\nTALAHON_VLLM_MODEL=new\n",
668 );
669 let process: HashMap<String, String> =
670 [("TALON_LOG".to_string(), "debug".to_string())].into();
671 let block = rename_block(
672 &env.legacy_keys_in(&process),
673 &resolve_config_dir(home.path()),
674 );
675 assert_eq!(
676 block.get("DEPRECATED_KEYS"),
677 Some(
678 "TALON_LOG -> TALAHON_LOG (environment),\
679 TALON_VLLM_BASE_URL -> TALAHON_VLLM_BASE_URL (.env),\
680 TALON_VLLM_MODEL -> TALAHON_VLLM_MODEL (.env)"
681 )
682 );
683 assert_eq!(
684 block.get("CONFLICTING_KEYS"),
685 Some("TALON_VLLM_MODEL -> TALAHON_VLLM_MODEL (.env)")
686 );
687 assert_eq!(block.get("CONFIG_DIR_NAME"), Some("legacy"));
688 assert_eq!(
689 block.get("CONFIG_DIR"),
690 Some(old_dir.display().to_string().as_str())
691 );
692 assert_eq!(block.get("IGNORED_CONFIG_DIR"), None);
693 let hint = block.get("HINT").unwrap();
694 assert!(hint.contains("TALAHON_*"), "{hint}");
695 assert!(hint.contains("ignored"), "{hint}");
696 assert!(hint.contains("~/.config/talahon"), "{hint}");
697 assert_eq!(block.get("STATUS"), Some("success"));
699 assert_eq!(env.get("TALAHON_VLLM_MODEL").as_deref(), Some("new"));
701
702 std::fs::create_dir_all(home.path().join(".config/talahon")).unwrap();
705 let block = rename_block(&[], &resolve_config_dir(home.path()));
706 assert_eq!(block.get("CONFIG_DIR_NAME"), Some("current"));
707 assert_eq!(
708 block.get("IGNORED_CONFIG_DIR"),
709 Some(old_dir.display().to_string().as_str())
710 );
711 assert!(block.get("HINT").unwrap().contains("NOT read"));
712 }
713
714 #[test]
715 fn issue_parsing_and_summary() {
716 let block = StatusBlock::new("CHECK_ENVIRONMENT")
717 .field("ISSUES", "image_missing,vllm_not_configured");
718 let issues = issues_of(Some(&block));
719 assert_eq!(issues, vec!["image_missing", "vllm_not_configured"]);
720 assert!(issues_of(Some(&StatusBlock::new("X").field("ISSUES", "none"))).is_empty());
721 assert!(issues_of(None).is_empty());
722
723 let summary = summary_block(&issues);
724 assert_eq!(summary.get("STATUS"), Some("failed"));
725 assert!(
726 summary
727 .get("HINT_2")
728 .unwrap()
729 .contains("TALAHON_VLLM_BASE_URL")
730 );
731 assert_eq!(summary_block(&[]).get("STATUS"), Some("success"));
732 }
733
734 fn cfg(content: &str) -> TalonConfig {
735 TalonConfig::from_env(
736 Path::new("/tmp/talon-doctor-test"),
737 talon_core::env::EnvFile::from_str_content(content),
738 )
739 }
740
741 #[test]
742 fn audit_block_reports_unkeyed_hint_and_key_problems() {
743 const KEY: &str = "7a9c1e3b5d7f9a0c2e4b6d8f0a1c3e5b7d9f1a3c5e7b9d0f2a4c6e8b0d1f3a5c";
744 let dir = tempfile::tempdir().unwrap();
745 let config = |env: &str| {
746 TalonConfig::from_env(dir.path(), talon_core::env::EnvFile::from_str_content(env))
747 };
748 let record = |config: &TalonConfig| {
749 let db = Db::open(&default_db_path(&config.store_dir)).unwrap();
750 crate::audit_cmd::enable_audit_key(&db, config).unwrap();
751 db.record_audit(&talon_core::audit::event(
752 "talon.start",
753 "talon",
754 None,
755 "info",
756 serde_json::json!({}),
757 ))
758 .unwrap();
759 };
760 assert!(audit_block(&config("")).is_none());
762
763 record(&config(""));
765 let (block, issues) = audit_block(&config("")).unwrap();
766 assert!(issues.is_empty(), "{issues:?}");
767 assert_eq!(block.get("STATUS"), Some("success"));
768 assert!(block.get("HINT").unwrap().contains("TALAHON_AUDIT_KEY"));
769
770 let (block, issues) = audit_block(&config("TALAHON_AUDIT_KEY=tooshort-pw\n")).unwrap();
772 assert_eq!(issues, vec!["audit_key_invalid"]);
773 assert!(!format!("{block}").contains("tooshort-pw"));
774 assert!(hint("audit_key_invalid").contains("openssl rand -hex 32"));
775
776 let keyed = config(&format!("TALAHON_AUDIT_KEY={KEY}\n"));
778 record(&keyed);
779 let (block, issues) = audit_block(&keyed).unwrap();
780 assert!(issues.is_empty(), "{block}");
781 assert_eq!(block.get("HINT"), None);
782 assert_eq!(block.get("CHECKPOINT"), Some("verified"));
783 assert!(!format!("{block}").contains(KEY));
784 let (block, issues) = audit_block(&config("")).unwrap();
785 assert_eq!(issues, vec!["audit_key_missing"]);
786 assert_eq!(block.get("CHAIN"), Some("unverifiable"));
787 assert!(hint("audit_key_missing").contains("TALAHON_AUDIT_KEY"));
788 }
789
790 fn env_overridden(keys: &[&str]) -> bool {
792 keys.iter().any(|k| talon_core::env::process_var_is_set(k))
793 }
794
795 #[test]
796 fn exposure_flags_missing_vllm_key_and_public_http_bind() {
797 if env_overridden(&[
798 "TALAHON_VLLM_BASE_URL",
799 "TALAHON_VLLM_MODEL",
800 "TALAHON_VLLM_ALLOW_NO_API_KEY",
801 "COPILOT_HTTP_BIND",
802 ]) {
803 return;
804 }
805 const VLLM: &str = "TALAHON_VLLM_BASE_URL=http://vllm:8000\nTALAHON_VLLM_MODEL=q\n";
806
807 let (block, issues) = exposure_block(&cfg(&format!("{VLLM}COPILOT_HTTP_BIND=0.0.0.0\n")));
808 assert_eq!(issues, vec!["vllm_api_key_unset", "http_api_public_bind"]);
809 assert_eq!(block.get("VLLM_API_KEY"), Some("unset"));
810 assert_eq!(block.get("STATUS"), Some("failed"));
811 let summary = summary_block(&issues);
812 assert!(
813 summary
814 .get("HINT_1")
815 .unwrap()
816 .contains("TALAHON_VLLM_API_KEY")
817 );
818 assert!(summary.get("HINT_2").unwrap().contains("127.0.0.1"));
819
820 let (_, issues) = exposure_block(&cfg(&format!("{VLLM}COPILOT_HTTP_BIND=::\n")));
821 assert!(issues.contains(&"http_api_public_bind".to_string()));
822
823 let (block, issues) = exposure_block(&cfg(&format!(
824 "{VLLM}TALAHON_VLLM_API_KEY=k\nCOPILOT_HTTP_BIND=127.0.0.1\n"
825 )));
826 assert!(issues.is_empty(), "{issues:?}");
827 assert_eq!(block.get("VLLM_API_KEY"), Some("set"));
828 assert_eq!(block.get("STATUS"), Some("success"));
829
830 let (block, issues) = exposure_block(&cfg(&format!(
831 "{VLLM}TALAHON_VLLM_ALLOW_NO_API_KEY=true\nCOPILOT_HTTP_BIND=10.0.0.5\n"
832 )));
833 assert!(issues.is_empty(), "{issues:?}");
834 assert_eq!(block.get("VLLM_API_KEY"), Some("unset_allowed"));
835
836 let (_, issues) = exposure_block(&cfg("COPILOT_HTTP_BIND=127.0.0.1\n"));
838 assert!(issues.is_empty(), "{issues:?}");
839
840 let (block, issues) = exposure_block(&cfg("COPILOT_HTTP_BIND=not-an-ip\n"));
841 assert_eq!(issues, vec!["http_config_invalid"]);
842 assert!(block.get("ERROR").is_some());
843 }
844
845 #[test]
846 fn network_inspect_classification() {
847 assert_eq!(classify_network_inspect(true, ""), NetworkState::Exists);
848 assert_eq!(
849 classify_network_inspect(
850 false,
851 "Error response from daemon: network talon-agents not found"
852 ),
853 NetworkState::Missing
854 );
855 assert_eq!(
856 classify_network_inspect(false, "Error: No such network: talon-agents"),
857 NetworkState::Missing
858 );
859 assert_eq!(
860 classify_network_inspect(
861 false,
862 "Error: unable to find network with name or ID talon-agents"
863 ),
864 NetworkState::Missing
865 );
866 assert_eq!(
867 classify_network_inspect(false, "Cannot connect to the Docker daemon"),
868 NetworkState::Unknown
869 );
870 }
871
872 #[tokio::test]
873 async fn network_inspect_runs_the_runtime_binary() {
874 let tmp = tempfile::tempdir().unwrap();
875 let fake = tmp.path().join("fake-docker");
876 std::fs::write(
877 &fake,
878 "#!/bin/sh\n[ \"$1 $2 $3\" = \"network inspect talon-agents\" ] && exit 0\n\
879 echo \"Error: No such network: $3\" >&2\nexit 1\n",
880 )
881 .unwrap();
882 use std::os::unix::fs::PermissionsExt;
883 std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
884 let bin = fake.to_string_lossy();
885 assert_eq!(
886 inspect_network(&bin, "talon-agents").await,
887 Ok(NetworkState::Exists)
888 );
889 assert_eq!(
890 inspect_network(&bin, "other").await,
891 Ok(NetworkState::Missing)
892 );
893 assert!(inspect_network("/nonexistent/docker", "x").await.is_err());
894 }
895
896 #[test]
897 fn every_known_issue_has_a_specific_hint() {
898 for issue in [
899 "container_runtime_missing",
900 "image_missing",
901 "vllm_not_configured",
902 "vllm_auth_failed",
903 "gateway_bind_loopback",
904 "vllm_api_key_unset",
905 "agent_network_missing",
906 "agent_network_unchecked",
907 "http_api_public_bind",
908 "http_config_invalid",
909 "http_api_key_unset",
910 "audit_chain_tampered",
911 "instructions_conflict",
912 "instructions_unsafe",
913 ] {
914 assert_ne!(hint(issue), hint("unknown_issue"), "{issue}");
915 }
916 }
917
918 #[test]
919 fn instructions_block_reports_legacy_conflicting_and_unsafe_files() {
920 use talon_security::instructions::{
921 AGENT_MOUNT_STUB, INSTRUCTIONS_PLACEHOLDER, LEGACY_MOUNT_STUB,
922 };
923 let dir = tempfile::tempdir().unwrap();
924 let config =
925 TalonConfig::from_env(dir.path(), talon_core::env::EnvFile::from_str_content(""));
926 let (block, issues) = instructions_block(&config);
928 assert_eq!(block.get("FOLDERS"), Some("0"));
929 assert_eq!(block.get("STATUS"), Some("success"));
930 assert!(issues.is_empty());
931
932 let groups = &config.groups_dir;
933 let write = |rel: &str, content: &str| {
934 let path = groups.join(rel);
935 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
936 std::fs::write(path, content).unwrap();
937 };
938 write("main/AGENT.md", "main rules");
940 write("main/CLAUDE.md", LEGACY_MOUNT_STUB);
941 write("global/AGENT.md", "global rules");
942 write("empty/AGENT.md", INSTRUCTIONS_PLACEHOLDER);
944 write("old-empty/CLAUDE.md", INSTRUCTIONS_PLACEHOLDER);
945 std::fs::create_dir_all(groups.join("bare")).unwrap();
946 write("copilot/CLAUDE.md", "old rules");
948 write("team/CLAUDE.md", "old rules");
949 write("team/AGENT.md", AGENT_MOUNT_STUB);
950 let (block, issues) = instructions_block(&config);
951 assert_eq!(block.get("FOLDERS"), Some("7"));
952 assert_eq!(block.get("LEGACY"), Some("copilot,team"));
953 assert_eq!(block.get("CONFLICT"), Some("none"));
954 assert_eq!(block.get("UNSAFE"), Some("none"));
955 assert!(
956 block
957 .get("HINT")
958 .unwrap()
959 .contains("mv groups/<folder>/CLAUDE.md")
960 );
961 assert_eq!(block.get("STATUS"), Some("success"));
963 assert!(issues.is_empty());
964
965 write("both/AGENT.md", "new rules");
968 write("both/CLAUDE.md", "old rules");
969 let (block, issues) = instructions_block(&config);
970 assert_eq!(block.get("CONFLICT"), Some("both"));
971 assert_eq!(block.get("LEGACY"), Some("copilot,team"));
972 assert_eq!(block.get("STATUS"), Some("failed"));
973 assert_eq!(issues, vec!["instructions_conflict"]);
974 assert!(hint("instructions_conflict").contains("keep the one you wrote"));
975 assert!(hint("instructions_conflict").contains("keep exactly one"));
976
977 std::fs::remove_file(groups.join("both/CLAUDE.md")).unwrap();
979 std::os::unix::fs::symlink("AGENT.md", groups.join("both/CLAUDE.md")).unwrap();
980 std::os::unix::fs::symlink("planted.md", groups.join("copilot/AGENT.md")).unwrap();
981 std::os::unix::fs::symlink(groups.join("main"), groups.join("alias")).unwrap();
982 let (block, issues) = instructions_block(&config);
983 assert_eq!(block.get("FOLDERS"), Some("8"));
984 let unsafe_entries = block.get("UNSAFE").unwrap();
985 assert!(
986 unsafe_entries.contains("both (CLAUDE.md is a symlink"),
987 "{unsafe_entries}"
988 );
989 assert!(
990 unsafe_entries.contains("copilot (AGENT.md is a symlink"),
991 "{unsafe_entries}"
992 );
993 assert_eq!(block.get("LEGACY"), Some("team"));
994 assert_eq!(issues, vec!["instructions_unsafe"]);
995
996 let many: Vec<String> = (0..60).map(|i| format!("g{i}")).collect();
997 assert!(listed(&many).ends_with(",... (10 more)"));
998 }
999}