Skip to main content

talon/
doctor.rs

1//! `talon doctor`: one command that explains why Talon can or cannot run.
2//!
3//! Prints machine-readable blocks (same format as `talon setup`):
4//! `CHECK_ENVIRONMENT` and `CHECK_VLLM` from `talon-setup`, then
5//! `CHECK_GATEWAY` (bind address the LLM gateway would use and whether
6//! containers can reach it), `CHECK_EXPOSURE` (vLLM API key requirement,
7//! HTTP API bind address — MYTH-05), `CHECK_NETWORK` (the dedicated agent
8//! network exists — MYTH-05), `AUDIT_VERIFY`, and a final `DOCTOR`
9//! summary with one hint per problem. Exit code 0 when no problem was
10//! found.
11
12use std::net::{IpAddr, Ipv4Addr};
13use std::path::Path;
14use std::process::Stdio;
15use std::time::Duration;
16
17use talon_container::runtime::network_inspect_args;
18use talon_container::{ContainerRuntime, HostPlatform, RunnerConfig};
19use talon_core::config::TalonConfig;
20use talon_core::provider::effective_vllm;
21use talon_db::{Db, DbOptions, default_db_path};
22use talon_http::HttpConfig;
23use talon_setup::checks::check_port;
24use talon_setup::{SetupContext, StatusBlock, Step, StepArgs, run_step};
25
26use crate::app::{ApiKeyPolicy, DEFAULT_CONTAINER_HOST, api_key_policy, gateway_bind_ip};
27
28/// Operator hint for an issue code.
29pub fn hint(issue: &str) -> &'static str {
30    match issue {
31        "container_runtime_missing" => "install docker or podman (CONTAINER_RUNTIME selects one)",
32        "container_runtime_not_running" => {
33            "start the container daemon (`docker info` must succeed)"
34        }
35        "configured_runtime_unavailable" => {
36            "CONTAINER_RUNTIME is not installed; another runtime was found and will be used"
37        }
38        "image_missing" => {
39            "build the agent image: ./container/build.sh (or `talon setup container`)"
40        }
41        "vllm_not_configured" => {
42            "set TALON_VLLM_BASE_URL and TALON_VLLM_MODEL in .env (see .env.example); without them \
43             the LLM gateway does not start and every agent run fails"
44        }
45        "vllm_invalid_url" => "TALON_VLLM_BASE_URL must be an http(s) URL, e.g. http://vllm:8000",
46        "vllm_unreachable" => {
47            "vLLM is not reachable from this host (network, firewall, server down)"
48        }
49        "vllm_auth_failed" => {
50            "vLLM rejected the key: set TALON_VLLM_API_KEY in .env to vLLM's --api-key"
51        }
52        "vllm_api_key_unset" => {
53            "TALON_VLLM_API_KEY is not set, so the LLM gateway refuses to start (without a key \
54             containers could call vLLM directly and bypass the gateway): start vLLM with \
55             --api-key and put the same value in .env; only if containers provably cannot reach \
56             vLLM, set TALON_VLLM_ALLOW_NO_API_KEY=true"
57        }
58        "vllm_model_missing" => {
59            "TALON_VLLM_MODEL is not served by vLLM (compare with --served-model-name / MODELS)"
60        }
61        "vllm_http_error" | "vllm_invalid_response" => {
62            "vLLM answered /v1/models unexpectedly; check TALON_VLLM_BASE_URL points at vLLM"
63        }
64        "gateway_port_in_use" => {
65            "TALON_LLM_GATEWAY_PORT is in use (expected while talon is running; otherwise pick another port)"
66        }
67        "gateway_port_unavailable" | "gateway_bind_unavailable" => {
68            "the LLM gateway cannot bind its address: set TALON_LLM_GATEWAY_BIND to a local address"
69        }
70        "gateway_bind_loopback" => {
71            "on Linux, containers cannot reach a gateway bound to 127.0.0.1: set \
72             TALON_LLM_GATEWAY_BIND to the docker bridge address (e.g. 172.17.0.1)"
73        }
74        "agent_network_missing" => {
75            "the agent container network (talon-agents) does not exist: run `talon setup egress` \
76             (creates it with inter-container traffic disabled plus the egress firewall rules); \
77             `talon run` also creates the network at startup, but not the firewall rules"
78        }
79        "agent_network_unchecked" => {
80            "the agent container network could not be inspected (is the container runtime running?)"
81        }
82        "dirs_not_writable" => {
83            "store/, data/, groups/ and logs/ must be writable by the service user"
84        }
85        "low_disk_space" => "free some disk space (images, logs, data/)",
86        "http_api_public_bind" => {
87            "the CoPilot HTTP API listens on every interface (COPILOT_HTTP_BIND=0.0.0.0 / ::): \
88             bind it to 127.0.0.1 or the private address CoPilot uses, and firewall the port"
89        }
90        "http_config_invalid" => {
91            "a COPILOT_HTTP_* setting is invalid (see CHECK_EXPOSURE ERROR); `talon run` refuses to start"
92        }
93        "http_api_key_unset" => {
94            "HTTP_API_KEY is not set: the CoPilot HTTP API rejects every request (openssl rand -hex 32)"
95        }
96        "audit_chain_tampered" => {
97            "the audit log 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
120/// `CHECK_GATEWAY` block plus issue codes.
121async 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
181/// `CHECK_EXPOSURE` block plus issue codes: the vLLM API key requirement
182/// (only when vLLM is configured — otherwise `vllm_not_configured` already
183/// says what is missing) and the HTTP API bind address (MYTH-05).
184pub 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
223/// How long `<runtime> network inspect` may take.
224const NETWORK_INSPECT_TIMEOUT: Duration = Duration::from_secs(20);
225
226/// Result of `<runtime> network inspect <name>`.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum NetworkState {
229    /// The network exists.
230    Exists,
231    /// The runtime says there is no such network.
232    Missing,
233    /// The inspection failed for another reason.
234    Unknown,
235}
236
237/// Classify a `network inspect` outcome.
238pub 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
271/// `CHECK_NETWORK` block plus issue codes: the network agent containers
272/// run on (`TALON_CONTAINER_NETWORK`, default `talon-agents`) must exist.
273/// Skipped without a container runtime (reported by the environment step).
274async 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
318/// `AUDIT_VERIFY` block plus issue codes (skipped without a database).
319fn 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        // An unkeyed chain is reported as a HINT in the block, not as an
350        // issue: it is the default.
351        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
369/// The final `DOCTOR` block.
370pub 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
388/// Run every check and print the blocks. Returns the exit code.
389pub 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    // The environment step already probed 127.0.0.1:<port>; the gateway
404    // block probes the real bind address, which supersedes it.
405    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        // No database yet: nothing to report.
483        assert!(audit_block(&config("")).is_none());
484
485        // Unkeyed chain: a hint, not an issue (the exit code is unchanged).
486        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        // Invalid key.
493        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        // Keyed chain: no hint; without the key afterwards: an issue.
499        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    /// The process environment overrides `.env` for non-secret keys.
513    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        // Without vLLM, `vllm_not_configured` (environment step) covers it.
559        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}