use std::net::{IpAddr, Ipv4Addr};
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use talon_container::runtime::network_inspect_args;
use talon_container::{ContainerRuntime, HostPlatform, RunnerConfig};
use talon_core::config::TalonConfig;
use talon_core::provider::effective_vllm;
use talon_db::{Db, DbOptions, default_db_path};
use talon_http::HttpConfig;
use talon_security::instructions::InstructionFiles;
use talon_setup::checks::check_port;
use talon_setup::{SetupContext, StatusBlock, Step, StepArgs, run_step};
use crate::app::{ApiKeyPolicy, DEFAULT_CONTAINER_HOST, api_key_policy, gateway_bind_ip};
pub fn hint(issue: &str) -> &'static str {
match issue {
"container_runtime_missing" => "install docker or podman (CONTAINER_RUNTIME selects one)",
"container_runtime_not_running" => {
"start the container daemon (`docker info` must succeed)"
}
"configured_runtime_unavailable" => {
"CONTAINER_RUNTIME is not installed; another runtime was found and will be used"
}
"image_missing" => {
"build the agent image: ./container/build.sh (or `talahon setup container`)"
}
"image_has_old_name" => {
"an image with the pre-rename name exists (CHECK_ENVIRONMENT LEGACY_IMAGE) but is \
not used: the agent image is now called talahon-agent. Rebuild it with \
./container/build.sh; to keep running the old image on purpose set \
CONTAINER_IMAGE to its name"
}
"vllm_not_configured" => {
"set TALAHON_VLLM_BASE_URL and TALAHON_VLLM_MODEL in .env (see .env.example); without them \
the LLM gateway does not start and every agent run fails"
}
"vllm_invalid_url" => "TALAHON_VLLM_BASE_URL must be an http(s) URL, e.g. http://vllm:8000",
"vllm_unreachable" => {
"vLLM is not reachable from this host (network, firewall, server down)"
}
"vllm_auth_failed" => {
"vLLM rejected the key: set TALAHON_VLLM_API_KEY in .env to vLLM's --api-key"
}
"vllm_api_key_unset" => {
"TALAHON_VLLM_API_KEY is not set, so the LLM gateway refuses to start (without a key \
containers could call vLLM directly and bypass the gateway): start vLLM with \
--api-key and put the same value in .env; only if containers provably cannot reach \
vLLM, set TALAHON_VLLM_ALLOW_NO_API_KEY=true"
}
"vllm_model_missing" => {
"TALAHON_VLLM_MODEL is not served by vLLM (compare with --served-model-name / MODELS)"
}
"vllm_http_error" | "vllm_invalid_response" => {
"vLLM answered /v1/models unexpectedly; check TALAHON_VLLM_BASE_URL points at vLLM"
}
"gateway_port_in_use" => {
"TALAHON_LLM_GATEWAY_PORT is in use (expected while talahon is running; otherwise pick another port)"
}
"gateway_port_unavailable" | "gateway_bind_unavailable" => {
"the LLM gateway cannot bind its address: set TALAHON_LLM_GATEWAY_BIND to a local address"
}
"gateway_bind_loopback" => {
"on Linux, containers cannot reach a gateway bound to 127.0.0.1: set \
TALAHON_LLM_GATEWAY_BIND to the docker bridge address (e.g. 172.17.0.1)"
}
"agent_network_missing" => {
"the agent container network (talon-agents) does not exist: run `talahon setup egress` \
(creates it with inter-container traffic disabled plus the egress firewall rules); \
`talahon run` also creates the network at startup, but not the firewall rules"
}
"agent_network_unchecked" => {
"the agent container network could not be inspected (is the container runtime running?)"
}
"dirs_not_writable" => {
"store/, data/, groups/ and logs/ must be writable by the service user"
}
"low_disk_space" => "free some disk space (images, logs, data/)",
"http_api_public_bind" => {
"the CoPilot HTTP API listens on every interface (COPILOT_HTTP_BIND=0.0.0.0 / ::): \
bind it to 127.0.0.1 or the private address CoPilot uses, and firewall the port"
}
"http_config_invalid" => {
"a COPILOT_HTTP_* setting is invalid (see CHECK_EXPOSURE ERROR); `talahon run` refuses to start"
}
"http_api_key_unset" => {
"HTTP_API_KEY is not set: the CoPilot HTTP API rejects every request (openssl rand -hex 32)"
}
"audit_chain_tampered" => {
"the audit log does not verify (see AUDIT_VERIFY ERROR): investigate with `talahon audit \
tail` before trusting it"
}
"audit_key_missing" => {
"the audit log has keyed rows but TALAHON_AUDIT_KEY is not set in .env: restore the key \
(`talahon run` refuses to start without it)"
}
"audit_key_invalid" => {
"TALAHON_AUDIT_KEY is too short or not random (openssl rand -hex 32); `talahon run` \
refuses to start"
}
"instructions_conflict" => {
"a group folder has both AGENT.md and CLAUDE.md with content (see CHECK_INSTRUCTIONS \
CONFLICT): every run in that folder is refused, because the file is the trusted \
system prompt and older versions did not protect the name AGENT.md. Inspect both \
files, keep the one you wrote and delete the other (keep exactly one; rename a kept \
CLAUDE.md to AGENT.md)"
}
"instructions_unsafe" => {
"AGENT.md or CLAUDE.md of a group folder is a symlink, directory or other non-regular \
file (see CHECK_INSTRUCTIONS UNSAFE): every run in that folder is refused. Replace \
it with a regular file, or remove it"
}
_ => "see the blocks above",
}
}
fn issues_of(block: Option<&StatusBlock>) -> Vec<String> {
block
.and_then(|b| b.get("ISSUES"))
.filter(|v| *v != "none" && !v.is_empty())
.map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default()
}
async fn gateway_block(config: &TalonConfig) -> (StatusBlock, Vec<String>) {
let mut issues = Vec::new();
let mut block = StatusBlock::new("CHECK_GATEWAY");
let explicit = config.env.get("TALAHON_LLM_GATEWAY_BIND").is_some();
block.set(
"BIND_SOURCE",
if explicit { "explicit" } else { "detected" },
);
let runtime = ContainerRuntime::detect(&config.container_runtime).ok();
let ip: Option<IpAddr> = match &runtime {
Some(rt) => match gateway_bind_ip(config, rt).await {
Ok(ip) => Some(ip),
Err(e) => {
block.set("ERROR", format!("{e:#}"));
issues.push("gateway_bind_unavailable".to_string());
None
}
},
None => config
.env
.get("TALAHON_LLM_GATEWAY_BIND")
.and_then(|v| v.trim().parse().ok())
.or(Some(IpAddr::V4(Ipv4Addr::LOCALHOST))),
};
if let Some(ip) = ip {
block.set("BIND", ip);
block.set("PORT", config.llm_gateway_port);
let port = check_port(&ip.to_string(), config.llm_gateway_port).await;
block.set("PORT_STATUS", port.as_str());
match port.as_str() {
"free" => {}
"in_use" => issues.push("gateway_port_in_use".to_string()),
_ => issues.push("gateway_bind_unavailable".to_string()),
}
let linux = runtime
.as_ref()
.is_some_and(|rt| rt.platform() == HostPlatform::Linux);
if linux && ip.is_loopback() {
issues.push("gateway_bind_loopback".to_string());
}
}
let host = config
.env
.get("TALAHON_LLM_GATEWAY_HOST")
.unwrap_or_else(|| DEFAULT_CONTAINER_HOST.to_string());
block.set(
"CONTAINER_URL",
format!("http://{host}:{}/v1", config.llm_gateway_port),
);
block.set(
"STATUS",
if issues.is_empty() {
"success"
} else {
"failed"
},
);
(block, issues)
}
pub fn exposure_block(config: &TalonConfig) -> (StatusBlock, Vec<String>) {
let mut issues = Vec::new();
let mut block = StatusBlock::new("CHECK_EXPOSURE");
let key = match api_key_policy(config) {
ApiKeyPolicy::Present => "set",
ApiKeyPolicy::MissingAllowed => "unset_allowed",
ApiKeyPolicy::Missing => {
if effective_vllm(&config.provider_defaults).is_some() {
issues.push("vllm_api_key_unset".to_string());
}
"unset"
}
};
block.set("VLLM_API_KEY", key);
match HttpConfig::from_talon_config(config) {
Ok(http) => {
block.set("HTTP_BIND", http.bind);
block.set("HTTP_PORT", http.port);
block.set("HTTP_TLS_TERMINATED", http.tls_terminated);
if http.bind.is_unspecified() {
issues.push("http_api_public_bind".to_string());
}
}
Err(e) => {
block.set("ERROR", e);
issues.push("http_config_invalid".to_string());
}
}
block.set(
"STATUS",
if issues.is_empty() {
"success"
} else {
"failed"
},
);
(block, issues)
}
const NETWORK_INSPECT_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkState {
Exists,
Missing,
Unknown,
}
pub fn classify_network_inspect(success: bool, stderr: &str) -> NetworkState {
if success {
return NetworkState::Exists;
}
let err = stderr.to_ascii_lowercase();
if err.contains("no such network")
|| err.contains("not found")
|| err.contains("unable to find network")
{
NetworkState::Missing
} else {
NetworkState::Unknown
}
}
async fn inspect_network(bin: &str, name: &str) -> Result<NetworkState, String> {
let child = tokio::process::Command::new(bin)
.args(network_inspect_args(name))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true)
.output();
match tokio::time::timeout(NETWORK_INSPECT_TIMEOUT, child).await {
Ok(Ok(out)) => Ok(classify_network_inspect(
out.status.success(),
&String::from_utf8_lossy(&out.stderr),
)),
Ok(Err(e)) => Err(format!("cannot run {bin}: {e}")),
Err(_) => Err(format!("{bin} network inspect timed out")),
}
}
async fn network_block(config: &TalonConfig) -> Option<(StatusBlock, Vec<String>)> {
let runtime = ContainerRuntime::detect(&config.container_runtime).ok()?;
let mut block = StatusBlock::new("CHECK_NETWORK");
let mut issues = Vec::new();
match RunnerConfig::from_talon_config(config).default_network {
None => {
block.set("NETWORK", "runtime-default");
block.set(
"NOTE",
"TALAHON_CONTAINER_NETWORK selects the runtime default bridge: containers can reach \
each other and `talahon setup egress` rules do not apply",
);
}
Some(network) => {
block.set("NETWORK", &network);
match inspect_network(runtime.bin(), &network).await {
Ok(NetworkState::Exists) => block.set("EXISTS", "yes"),
Ok(NetworkState::Missing) => {
block.set("EXISTS", "no");
issues.push("agent_network_missing".to_string());
}
Ok(NetworkState::Unknown) => {
block.set("EXISTS", "unknown");
issues.push("agent_network_unchecked".to_string());
}
Err(e) => {
block.set("EXISTS", "unknown");
block.set("ERROR", e);
issues.push("agent_network_unchecked".to_string());
}
}
}
}
block.set(
"STATUS",
if issues.is_empty() {
"success"
} else {
"failed"
},
);
Some((block, issues))
}
const MAX_LISTED_FOLDERS: usize = 50;
fn listed(names: &[String]) -> String {
if names.is_empty() {
return "none".to_string();
}
let mut out = names
.iter()
.take(MAX_LISTED_FOLDERS)
.map(String::as_str)
.collect::<Vec<_>>()
.join(",");
if names.len() > MAX_LISTED_FOLDERS {
out.push_str(&format!(",... ({} more)", names.len() - MAX_LISTED_FOLDERS));
}
out
}
pub fn instructions_block(config: &TalonConfig) -> (StatusBlock, Vec<String>) {
let mut block = StatusBlock::new("CHECK_INSTRUCTIONS");
let mut issues = Vec::new();
let mut folders: Vec<String> = std::fs::read_dir(&config.groups_dir)
.map(|entries| {
entries
.filter_map(Result::ok)
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| e.file_name().into_string().ok())
.collect()
})
.unwrap_or_default();
folders.sort();
let (mut legacy, mut conflicts, mut unsafe_entries) = (Vec::new(), Vec::new(), Vec::new());
for folder in &folders {
let files = match InstructionFiles::inspect(&config.groups_dir.join(folder)) {
Ok(files) => files,
Err(e) => {
unsafe_entries.push(format!("{folder} (cannot be inspected: {e})"));
continue;
}
};
if let Some(found) = files.unsafe_entry() {
unsafe_entries.push(format!("{folder} ({found})"));
} else if files.is_conflict() {
conflicts.push(folder.clone());
} else if files.uses_legacy() {
legacy.push(folder.clone());
}
}
block.set("FOLDERS", folders.len());
block.set("LEGACY", listed(&legacy));
block.set("CONFLICT", listed(&conflicts));
block.set("UNSAFE", listed(&unsafe_entries));
if !legacy.is_empty() {
block.set(
"HINT",
"the LEGACY folders still keep their instructions in CLAUDE.md, which is read as \
before. Rename it when convenient: `mv groups/<folder>/CLAUDE.md \
groups/<folder>/AGENT.md` (this replaces the AGENT.md mount-point stub Talahon put \
there; use `git mv -f` for tracked files)",
);
}
if !conflicts.is_empty() {
issues.push("instructions_conflict".to_string());
}
if !unsafe_entries.is_empty() {
issues.push("instructions_unsafe".to_string());
}
block.set(
"STATUS",
if issues.is_empty() {
"success"
} else {
"failed"
},
);
(block, issues)
}
pub fn rename_block(
keys: &[talon_core::env::LegacyKey],
config_dir: &talon_core::paths::ConfigDir,
) -> StatusBlock {
use talon_core::env::EnvSource;
let mut block = StatusBlock::new("CHECK_RENAME");
let describe = |k: &talon_core::env::LegacyKey| {
let source = match k.source {
EnvSource::File => ".env",
EnvSource::Process => "environment",
};
format!("{} -> {} ({source})", k.legacy, k.current)
};
let deprecated: Vec<String> = keys.iter().map(describe).collect();
let conflicts: Vec<String> = keys.iter().filter(|k| k.conflict).map(describe).collect();
block.set("DEPRECATED_KEYS", listed(&deprecated));
block.set("CONFLICTING_KEYS", listed(&conflicts));
block.set("CONFIG_DIR", config_dir.path.display());
block.set(
"CONFIG_DIR_NAME",
if config_dir.legacy {
"legacy"
} else {
"current"
},
);
if let Some(ignored) = &config_dir.ignored_legacy {
block.set("IGNORED_CONFIG_DIR", ignored.display());
}
let mut hints: Vec<String> = Vec::new();
if !deprecated.is_empty() {
hints.push(
"rename the DEPRECATED_KEYS (TALON_* -> TALAHON_*); the old spellings are read \
until the next major version"
.into(),
);
}
if !conflicts.is_empty() {
hints.push(
"CONFLICTING_KEYS are set under both names with different values: the TALAHON_* \
value is used, the TALON_* value is ignored; delete the old key"
.into(),
);
}
if config_dir.legacy {
hints.push(format!(
"the mount and sender allowlists are read from {} (pre-rename name); rename the \
directory to ~/.config/{} when convenient",
config_dir.path.display(),
talon_core::paths::CONFIG_DIR_NAME
));
}
if let Some(ignored) = &config_dir.ignored_legacy {
hints.push(format!(
"{} is NOT read (only CONFIG_DIR is; the two are never merged): move anything \
still needed from it, then delete it",
ignored.display()
));
}
if !hints.is_empty() {
block.set("HINT", hints.join("; "));
}
block.set("STATUS", "success");
block
}
fn audit_block(config: &TalonConfig) -> Option<(StatusBlock, Vec<String>)> {
let path = default_db_path(&config.store_dir);
if !path.exists() {
return None;
}
let db = match Db::open_with_options(
&path,
&DbOptions {
assistant_name: config.assistant_name.clone(),
},
) {
Ok(db) => db,
Err(e) => {
return Some((
StatusBlock::new("AUDIT_VERIFY")
.field("STATUS", "failed")
.field("ERROR", e),
vec!["audit_chain_tampered".to_string()],
));
}
};
if let Err(e) = crate::audit_cmd::enable_audit_key(&db, config) {
return Some((
StatusBlock::new("AUDIT_VERIFY")
.field("STATUS", "failed")
.field("ERROR", format!("{e:#}")),
vec!["audit_key_invalid".to_string()],
));
}
match crate::audit_cmd::verify_block(&db) {
Ok((block, true)) => Some((block, Vec::new())),
Ok((block, false)) => {
let issue = if block.get("CHAIN") == Some("unverifiable") {
"audit_key_missing"
} else {
"audit_chain_tampered"
};
Some((block, vec![issue.to_string()]))
}
Err(e) => Some((
StatusBlock::new("AUDIT_VERIFY")
.field("STATUS", "failed")
.field("ERROR", format!("{e:#}")),
Vec::new(),
)),
}
}
pub fn summary_block(issues: &[String]) -> StatusBlock {
let mut block = StatusBlock::new("DOCTOR").field("VERSION", crate::VERSION);
if issues.is_empty() {
block.set("ISSUES", "none");
block.set("STATUS", "success");
return block;
}
block.set("ISSUES", issues.join(","));
for (i, issue) in issues.iter().enumerate() {
block.set(
&format!("HINT_{}", i + 1),
format!("{issue}: {}", hint(issue)),
);
}
block.set("STATUS", "failed");
block
}
pub async fn run(project_root: &Path, config: &TalonConfig) -> i32 {
let ctx = SetupContext::detect(project_root);
let no_args = StepArgs::new(Vec::<String>::new());
let mut issues: Vec<String> = Vec::new();
let env = run_step(Step::Environment, &no_args, &ctx).await;
println!("{}", env.render());
issues.extend(issues_of(env.block.as_ref()));
let vllm = run_step(Step::Vllm, &no_args, &ctx).await;
println!("{}", vllm.render());
let (gateway, gw_issues) = gateway_block(config).await;
println!("{gateway}");
issues.retain(|i| !i.starts_with("gateway_port_"));
issues.extend(gw_issues);
let (exposure, exposure_issues) = exposure_block(config);
println!("{exposure}");
issues.extend(exposure_issues);
if let Some((network, network_issues)) = network_block(config).await {
println!("{network}");
issues.extend(network_issues);
}
if config.env.file_value("HTTP_API_KEY").is_none() {
issues.push("http_api_key_unset".to_string());
}
let (instructions, instruction_issues) = instructions_block(config);
println!("{instructions}");
issues.extend(instruction_issues);
println!(
"{}",
rename_block(
&config.env.legacy_keys(),
&talon_core::paths::resolve_config_dir(
config
.config_dir
.parent()
.and_then(Path::parent)
.unwrap_or(&ctx.home_dir),
),
)
);
if let Some((block, audit_issues)) = audit_block(config) {
println!("{block}");
issues.extend(audit_issues);
}
let mut seen = std::collections::HashSet::new();
issues.retain(|i| seen.insert(i.clone()));
println!("{}", summary_block(&issues));
if issues.is_empty() { 0 } else { 1 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rename_block_lists_deprecated_keys_conflicts_and_the_config_dir() {
use std::collections::HashMap;
use talon_core::env::EnvFile;
use talon_core::paths::resolve_config_dir;
let home = tempfile::tempdir().unwrap();
let clean = rename_block(
&EnvFile::from_str_content("TALAHON_VLLM_MODEL=q\n").legacy_keys_in(&HashMap::new()),
&resolve_config_dir(home.path()),
);
assert_eq!(clean.get("DEPRECATED_KEYS"), Some("none"));
assert_eq!(clean.get("CONFLICTING_KEYS"), Some("none"));
assert_eq!(clean.get("CONFIG_DIR_NAME"), Some("current"));
assert_eq!(clean.get("HINT"), None);
assert_eq!(clean.get("STATUS"), Some("success"));
let old_dir = home.path().join(".config/talon");
std::fs::create_dir_all(&old_dir).unwrap();
let env = EnvFile::from_str_content(
"TALON_VLLM_BASE_URL=http://old:8000\nTALON_VLLM_MODEL=old\nTALAHON_VLLM_MODEL=new\n",
);
let process: HashMap<String, String> =
[("TALON_LOG".to_string(), "debug".to_string())].into();
let block = rename_block(
&env.legacy_keys_in(&process),
&resolve_config_dir(home.path()),
);
assert_eq!(
block.get("DEPRECATED_KEYS"),
Some(
"TALON_LOG -> TALAHON_LOG (environment),\
TALON_VLLM_BASE_URL -> TALAHON_VLLM_BASE_URL (.env),\
TALON_VLLM_MODEL -> TALAHON_VLLM_MODEL (.env)"
)
);
assert_eq!(
block.get("CONFLICTING_KEYS"),
Some("TALON_VLLM_MODEL -> TALAHON_VLLM_MODEL (.env)")
);
assert_eq!(block.get("CONFIG_DIR_NAME"), Some("legacy"));
assert_eq!(
block.get("CONFIG_DIR"),
Some(old_dir.display().to_string().as_str())
);
assert_eq!(block.get("IGNORED_CONFIG_DIR"), None);
let hint = block.get("HINT").unwrap();
assert!(hint.contains("TALAHON_*"), "{hint}");
assert!(hint.contains("ignored"), "{hint}");
assert!(hint.contains("~/.config/talahon"), "{hint}");
assert_eq!(block.get("STATUS"), Some("success"));
assert_eq!(env.get("TALAHON_VLLM_MODEL").as_deref(), Some("new"));
std::fs::create_dir_all(home.path().join(".config/talahon")).unwrap();
let block = rename_block(&[], &resolve_config_dir(home.path()));
assert_eq!(block.get("CONFIG_DIR_NAME"), Some("current"));
assert_eq!(
block.get("IGNORED_CONFIG_DIR"),
Some(old_dir.display().to_string().as_str())
);
assert!(block.get("HINT").unwrap().contains("NOT read"));
}
#[test]
fn issue_parsing_and_summary() {
let block = StatusBlock::new("CHECK_ENVIRONMENT")
.field("ISSUES", "image_missing,vllm_not_configured");
let issues = issues_of(Some(&block));
assert_eq!(issues, vec!["image_missing", "vllm_not_configured"]);
assert!(issues_of(Some(&StatusBlock::new("X").field("ISSUES", "none"))).is_empty());
assert!(issues_of(None).is_empty());
let summary = summary_block(&issues);
assert_eq!(summary.get("STATUS"), Some("failed"));
assert!(
summary
.get("HINT_2")
.unwrap()
.contains("TALAHON_VLLM_BASE_URL")
);
assert_eq!(summary_block(&[]).get("STATUS"), Some("success"));
}
fn cfg(content: &str) -> TalonConfig {
TalonConfig::from_env(
Path::new("/tmp/talon-doctor-test"),
talon_core::env::EnvFile::from_str_content(content),
)
}
#[test]
fn audit_block_reports_unkeyed_hint_and_key_problems() {
const KEY: &str = "7a9c1e3b5d7f9a0c2e4b6d8f0a1c3e5b7d9f1a3c5e7b9d0f2a4c6e8b0d1f3a5c";
let dir = tempfile::tempdir().unwrap();
let config = |env: &str| {
TalonConfig::from_env(dir.path(), talon_core::env::EnvFile::from_str_content(env))
};
let record = |config: &TalonConfig| {
let db = Db::open(&default_db_path(&config.store_dir)).unwrap();
crate::audit_cmd::enable_audit_key(&db, config).unwrap();
db.record_audit(&talon_core::audit::event(
"talon.start",
"talon",
None,
"info",
serde_json::json!({}),
))
.unwrap();
};
assert!(audit_block(&config("")).is_none());
record(&config(""));
let (block, issues) = audit_block(&config("")).unwrap();
assert!(issues.is_empty(), "{issues:?}");
assert_eq!(block.get("STATUS"), Some("success"));
assert!(block.get("HINT").unwrap().contains("TALAHON_AUDIT_KEY"));
let (block, issues) = audit_block(&config("TALAHON_AUDIT_KEY=tooshort-pw\n")).unwrap();
assert_eq!(issues, vec!["audit_key_invalid"]);
assert!(!format!("{block}").contains("tooshort-pw"));
assert!(hint("audit_key_invalid").contains("openssl rand -hex 32"));
let keyed = config(&format!("TALAHON_AUDIT_KEY={KEY}\n"));
record(&keyed);
let (block, issues) = audit_block(&keyed).unwrap();
assert!(issues.is_empty(), "{block}");
assert_eq!(block.get("HINT"), None);
assert_eq!(block.get("CHECKPOINT"), Some("verified"));
assert!(!format!("{block}").contains(KEY));
let (block, issues) = audit_block(&config("")).unwrap();
assert_eq!(issues, vec!["audit_key_missing"]);
assert_eq!(block.get("CHAIN"), Some("unverifiable"));
assert!(hint("audit_key_missing").contains("TALAHON_AUDIT_KEY"));
}
fn env_overridden(keys: &[&str]) -> bool {
keys.iter().any(|k| talon_core::env::process_var_is_set(k))
}
#[test]
fn exposure_flags_missing_vllm_key_and_public_http_bind() {
if env_overridden(&[
"TALAHON_VLLM_BASE_URL",
"TALAHON_VLLM_MODEL",
"TALAHON_VLLM_ALLOW_NO_API_KEY",
"COPILOT_HTTP_BIND",
]) {
return;
}
const VLLM: &str = "TALAHON_VLLM_BASE_URL=http://vllm:8000\nTALAHON_VLLM_MODEL=q\n";
let (block, issues) = exposure_block(&cfg(&format!("{VLLM}COPILOT_HTTP_BIND=0.0.0.0\n")));
assert_eq!(issues, vec!["vllm_api_key_unset", "http_api_public_bind"]);
assert_eq!(block.get("VLLM_API_KEY"), Some("unset"));
assert_eq!(block.get("STATUS"), Some("failed"));
let summary = summary_block(&issues);
assert!(
summary
.get("HINT_1")
.unwrap()
.contains("TALAHON_VLLM_API_KEY")
);
assert!(summary.get("HINT_2").unwrap().contains("127.0.0.1"));
let (_, issues) = exposure_block(&cfg(&format!("{VLLM}COPILOT_HTTP_BIND=::\n")));
assert!(issues.contains(&"http_api_public_bind".to_string()));
let (block, issues) = exposure_block(&cfg(&format!(
"{VLLM}TALAHON_VLLM_API_KEY=k\nCOPILOT_HTTP_BIND=127.0.0.1\n"
)));
assert!(issues.is_empty(), "{issues:?}");
assert_eq!(block.get("VLLM_API_KEY"), Some("set"));
assert_eq!(block.get("STATUS"), Some("success"));
let (block, issues) = exposure_block(&cfg(&format!(
"{VLLM}TALAHON_VLLM_ALLOW_NO_API_KEY=true\nCOPILOT_HTTP_BIND=10.0.0.5\n"
)));
assert!(issues.is_empty(), "{issues:?}");
assert_eq!(block.get("VLLM_API_KEY"), Some("unset_allowed"));
let (_, issues) = exposure_block(&cfg("COPILOT_HTTP_BIND=127.0.0.1\n"));
assert!(issues.is_empty(), "{issues:?}");
let (block, issues) = exposure_block(&cfg("COPILOT_HTTP_BIND=not-an-ip\n"));
assert_eq!(issues, vec!["http_config_invalid"]);
assert!(block.get("ERROR").is_some());
}
#[test]
fn network_inspect_classification() {
assert_eq!(classify_network_inspect(true, ""), NetworkState::Exists);
assert_eq!(
classify_network_inspect(
false,
"Error response from daemon: network talon-agents not found"
),
NetworkState::Missing
);
assert_eq!(
classify_network_inspect(false, "Error: No such network: talon-agents"),
NetworkState::Missing
);
assert_eq!(
classify_network_inspect(
false,
"Error: unable to find network with name or ID talon-agents"
),
NetworkState::Missing
);
assert_eq!(
classify_network_inspect(false, "Cannot connect to the Docker daemon"),
NetworkState::Unknown
);
}
#[tokio::test]
async fn network_inspect_runs_the_runtime_binary() {
let tmp = tempfile::tempdir().unwrap();
let fake = tmp.path().join("fake-docker");
std::fs::write(
&fake,
"#!/bin/sh\n[ \"$1 $2 $3\" = \"network inspect talon-agents\" ] && exit 0\n\
echo \"Error: No such network: $3\" >&2\nexit 1\n",
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let bin = fake.to_string_lossy();
assert_eq!(
inspect_network(&bin, "talon-agents").await,
Ok(NetworkState::Exists)
);
assert_eq!(
inspect_network(&bin, "other").await,
Ok(NetworkState::Missing)
);
assert!(inspect_network("/nonexistent/docker", "x").await.is_err());
}
#[test]
fn every_known_issue_has_a_specific_hint() {
for issue in [
"container_runtime_missing",
"image_missing",
"vllm_not_configured",
"vllm_auth_failed",
"gateway_bind_loopback",
"vllm_api_key_unset",
"agent_network_missing",
"agent_network_unchecked",
"http_api_public_bind",
"http_config_invalid",
"http_api_key_unset",
"audit_chain_tampered",
"instructions_conflict",
"instructions_unsafe",
] {
assert_ne!(hint(issue), hint("unknown_issue"), "{issue}");
}
}
#[test]
fn instructions_block_reports_legacy_conflicting_and_unsafe_files() {
use talon_security::instructions::{
AGENT_MOUNT_STUB, INSTRUCTIONS_PLACEHOLDER, LEGACY_MOUNT_STUB,
};
let dir = tempfile::tempdir().unwrap();
let config =
TalonConfig::from_env(dir.path(), talon_core::env::EnvFile::from_str_content(""));
let (block, issues) = instructions_block(&config);
assert_eq!(block.get("FOLDERS"), Some("0"));
assert_eq!(block.get("STATUS"), Some("success"));
assert!(issues.is_empty());
let groups = &config.groups_dir;
let write = |rel: &str, content: &str| {
let path = groups.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
};
write("main/AGENT.md", "main rules");
write("main/CLAUDE.md", LEGACY_MOUNT_STUB);
write("global/AGENT.md", "global rules");
write("empty/AGENT.md", INSTRUCTIONS_PLACEHOLDER);
write("old-empty/CLAUDE.md", INSTRUCTIONS_PLACEHOLDER);
std::fs::create_dir_all(groups.join("bare")).unwrap();
write("copilot/CLAUDE.md", "old rules");
write("team/CLAUDE.md", "old rules");
write("team/AGENT.md", AGENT_MOUNT_STUB);
let (block, issues) = instructions_block(&config);
assert_eq!(block.get("FOLDERS"), Some("7"));
assert_eq!(block.get("LEGACY"), Some("copilot,team"));
assert_eq!(block.get("CONFLICT"), Some("none"));
assert_eq!(block.get("UNSAFE"), Some("none"));
assert!(
block
.get("HINT")
.unwrap()
.contains("mv groups/<folder>/CLAUDE.md")
);
assert_eq!(block.get("STATUS"), Some("success"));
assert!(issues.is_empty());
write("both/AGENT.md", "new rules");
write("both/CLAUDE.md", "old rules");
let (block, issues) = instructions_block(&config);
assert_eq!(block.get("CONFLICT"), Some("both"));
assert_eq!(block.get("LEGACY"), Some("copilot,team"));
assert_eq!(block.get("STATUS"), Some("failed"));
assert_eq!(issues, vec!["instructions_conflict"]);
assert!(hint("instructions_conflict").contains("keep the one you wrote"));
assert!(hint("instructions_conflict").contains("keep exactly one"));
std::fs::remove_file(groups.join("both/CLAUDE.md")).unwrap();
std::os::unix::fs::symlink("AGENT.md", groups.join("both/CLAUDE.md")).unwrap();
std::os::unix::fs::symlink("planted.md", groups.join("copilot/AGENT.md")).unwrap();
std::os::unix::fs::symlink(groups.join("main"), groups.join("alias")).unwrap();
let (block, issues) = instructions_block(&config);
assert_eq!(block.get("FOLDERS"), Some("8"));
let unsafe_entries = block.get("UNSAFE").unwrap();
assert!(
unsafe_entries.contains("both (CLAUDE.md is a symlink"),
"{unsafe_entries}"
);
assert!(
unsafe_entries.contains("copilot (AGENT.md is a symlink"),
"{unsafe_entries}"
);
assert_eq!(block.get("LEGACY"), Some("team"));
assert_eq!(issues, vec!["instructions_unsafe"]);
let many: Vec<String> = (0..60).map(|i| format!("g{i}")).collect();
assert!(listed(&many).ends_with(",... (10 more)"));
}
}