Skip to main content

talon/
doctor.rs

1//! `talahon doctor`: one command that explains why Talahon can or cannot run.
2//!
3//! Prints machine-readable blocks (same format as `talahon 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), `CHECK_INSTRUCTIONS` (which file holds each
9//! group folder's instructions: `AGENT.md`, or the legacy `CLAUDE.md` —
10//! MYTH-09), `CHECK_RENAME` (what this installation still uses under the
11//! pre-rename name `talon`: `TALON_*` keys, `~/.config/talon`), `AUDIT_VERIFY`,
12//! and a final `DOCTOR` summary with one hint per problem. Exit code 0 when
13//! no problem was found.
14
15use 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
32/// Operator hint for an issue code.
33pub 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
142/// `CHECK_GATEWAY` block plus issue codes.
143async 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
203/// `CHECK_EXPOSURE` block plus issue codes: the vLLM API key requirement
204/// (only when vLLM is configured — otherwise `vllm_not_configured` already
205/// says what is missing) and the HTTP API bind address (MYTH-05).
206pub 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
245/// How long `<runtime> network inspect` may take.
246const NETWORK_INSPECT_TIMEOUT: Duration = Duration::from_secs(20);
247
248/// Result of `<runtime> network inspect <name>`.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum NetworkState {
251    /// The network exists.
252    Exists,
253    /// The runtime says there is no such network.
254    Missing,
255    /// The inspection failed for another reason.
256    Unknown,
257}
258
259/// Classify a `network inspect` outcome.
260pub 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
293/// `CHECK_NETWORK` block plus issue codes: the network agent containers
294/// run on (`TALAHON_CONTAINER_NETWORK`, default `talon-agents`) must exist.
295/// Skipped without a container runtime (reported by the environment step).
296async 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
340/// Most folder names listed per field of `CHECK_INSTRUCTIONS`.
341const 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
359/// `CHECK_INSTRUCTIONS` block plus issue codes: which file holds the
360/// instructions of each folder under `groups/` (`groups/global` included).
361///
362/// - `LEGACY`: folders that still read `CLAUDE.md` (the fallback). Not a
363///   problem: a `HINT` says how to rename the file.
364/// - `CONFLICT`: folders in which both names have content; runs there are
365///   refused until the operator keeps exactly one (`instructions_conflict`).
366/// - `UNSAFE`: folders in which either name is a symlink or another
367///   non-regular file; runs there are refused (`instructions_unsafe`).
368///
369/// Symlinked folders are skipped (lanes refuse them anyway).
370pub 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
430/// `CHECK_RENAME` block: what this installation still uses under the name
431/// the project had up to 2.x. Nothing in it is a problem (everything listed
432/// keeps working until the next major version), so it never adds an issue.
433///
434/// - `DEPRECATED_KEYS`: `TALON_*` keys found in `.env` or the process
435///   environment, each with the spelling to rename it to.
436/// - `CONFLICTING_KEYS`: old keys whose `TALAHON_*` counterpart is set to a
437///   different value. The new one wins; the old value is ignored.
438/// - `CONFIG_DIR` / `CONFIG_DIR_NAME`: the one configuration directory in
439///   effect (`current` = `~/.config/talahon`, `legacy` = `~/.config/talon`).
440/// - `IGNORED_CONFIG_DIR`: `~/.config/talon` when both exist (never merged).
441pub 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
507/// `AUDIT_VERIFY` block plus issue codes (skipped without a database).
508fn 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        // An unkeyed chain is reported as a HINT in the block, not as an
539        // issue: it is the default.
540        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
558/// The final `DOCTOR` block.
559pub 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
577/// Run every check and print the blocks. Returns the exit code.
578pub 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    // The environment step already probed 127.0.0.1:<port>; the gateway
593    // block probes the real bind address, which supersedes it.
594    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            // The same home the configuration was resolved against.
619            &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        // Up to date: nothing to report.
652        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        // Upgraded from 2.x: old keys, one of them contradicting the new
663        // spelling, and only ~/.config/talon.
664        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        // Deprecated spellings are not a failure.
698        assert_eq!(block.get("STATUS"), Some("success"));
699        // The new value is the one in effect.
700        assert_eq!(env.get("TALAHON_VLLM_MODEL").as_deref(), Some("new"));
701
702        // Both directories: the new one is authoritative, the old one is
703        // named as ignored.
704        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        // No database yet: nothing to report.
761        assert!(audit_block(&config("")).is_none());
762
763        // Unkeyed chain: a hint, not an issue (the exit code is unchanged).
764        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        // Invalid key.
771        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        // Keyed chain: no hint; without the key afterwards: an issue.
777        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    /// The process environment overrides `.env` for non-secret keys.
791    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        // Without vLLM, `vllm_not_configured` (environment step) covers it.
837        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        // No groups directory at all.
927        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        // Migrated folders (with the stub the host leaves at the old name).
939        write("main/AGENT.md", "main rules");
940        write("main/CLAUDE.md", LEGACY_MOUNT_STUB);
941        write("global/AGENT.md", "global rules");
942        // No instructions.
943        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        // Fallback in use: before the first run, and after it (stub).
947        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        // A hint, not an error.
962        assert_eq!(block.get("STATUS"), Some("success"));
963        assert!(issues.is_empty());
964
965        // Both names with content: runs are refused; reported as a
966        // problem with the fix.
967        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        // A symlink under either name (and a symlinked folder is skipped).
978        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}