Skip to main content

zeroclaw/commands/
self_test.rs

1//! `zeroclaw self-test` — quick and full diagnostic checks.
2
3use anyhow::Result;
4use std::path::Path;
5use zeroclaw_runtime::i18n::get_required_cli_string_with_args;
6
7/// Result of a single diagnostic check.
8pub struct CheckResult {
9    pub name: &'static str,
10    pub passed: bool,
11    pub detail: String,
12}
13
14impl CheckResult {
15    fn pass(name: &'static str, detail: impl Into<String>) -> Self {
16        Self {
17            name,
18            passed: true,
19            detail: detail.into(),
20        }
21    }
22    fn fail(name: &'static str, detail: impl Into<String>) -> Self {
23        Self {
24            name,
25            passed: false,
26            detail: detail.into(),
27        }
28    }
29}
30
31/// Run the quick self-test suite (no network required).
32pub async fn run_quick(config: &crate::config::Config) -> Result<Vec<CheckResult>> {
33    let mut results = Vec::new();
34
35    // 1. Config file exists and parses
36    results.push(check_config(config));
37
38    // 2. Workspace directory is writable
39    results.push(check_workspace(&config.data_dir).await);
40
41    // 3. SQLite memory backend opens
42    results.push(check_sqlite(&config.data_dir));
43
44    // 4. ModelProvider registry has entries
45    results.push(check_model_provider_registry());
46
47    // 5. Tool registry has entries
48    results.push(check_tool_registry(config));
49
50    // 6. Channel registry loads
51    results.push(check_channel_config(config));
52
53    // 7. Security policy parses
54    results.push(check_security_policy(config));
55
56    // 8. Version sanity
57    results.push(check_version());
58
59    // 9. gateway.web_dist_dir is a literal path (no shell-style expansion)
60    results.push(check_web_dist_dir(config));
61
62    Ok(results)
63}
64
65/// Run the full self-test suite (includes network checks).
66pub async fn run_full(config: &crate::config::Config) -> Result<Vec<CheckResult>> {
67    let mut results = run_quick(config).await?;
68
69    // 9. Gateway health endpoint
70    results.push(check_gateway_health(config).await);
71
72    // 10. Memory write/read round-trip
73    results.push(check_memory_roundtrip(config).await);
74
75    // 11. WebSocket handshake
76    #[cfg(feature = "gateway")]
77    results.push(check_websocket_handshake(config).await);
78
79    Ok(results)
80}
81
82/// Print results in a formatted table.
83pub fn print_results(results: &[CheckResult]) {
84    let total = results.len();
85    let passed = results.iter().filter(|r| r.passed).count();
86    let failed = total - passed;
87
88    println!();
89    for (i, r) in results.iter().enumerate() {
90        let icon = if r.passed {
91            "\x1b[32m✓\x1b[0m"
92        } else {
93            "\x1b[31m✗\x1b[0m"
94        };
95        println!("  {} {}/{} {} — {}", icon, i + 1, total, r.name, r.detail);
96    }
97    println!();
98    if failed == 0 {
99        println!(
100            "  \x1b[32m{}\x1b[0m",
101            get_required_cli_string_with_args(
102                "cli-selftest-all-passed",
103                &[("total", &total.to_string())]
104            )
105        );
106    } else {
107        println!(
108            "  \x1b[31m{}\x1b[0m",
109            get_required_cli_string_with_args(
110                "cli-selftest-some-failed",
111                &[
112                    ("failed", &failed.to_string()),
113                    ("total", &total.to_string())
114                ],
115            )
116        );
117    }
118    println!();
119}
120
121fn check_config(config: &crate::config::Config) -> CheckResult {
122    if config.config_path.exists() {
123        CheckResult::pass(
124            "config",
125            format!("loaded from {}", config.config_path.display()),
126        )
127    } else {
128        CheckResult::fail("config", "config file not found (using defaults)")
129    }
130}
131
132async fn check_workspace(workspace_dir: &Path) -> CheckResult {
133    match tokio::fs::metadata(workspace_dir).await {
134        Ok(meta) if meta.is_dir() => {
135            // Try writing a temp file
136            let test_file = workspace_dir.join(".selftest_probe");
137            match tokio::fs::write(&test_file, b"ok").await {
138                Ok(()) => {
139                    let _ = tokio::fs::remove_file(&test_file).await;
140                    CheckResult::pass(
141                        "workspace",
142                        format!("{} (writable)", workspace_dir.display()),
143                    )
144                }
145                Err(e) => CheckResult::fail(
146                    "workspace",
147                    format!("{} (not writable: {e})", workspace_dir.display()),
148                ),
149            }
150        }
151        Ok(_) => CheckResult::fail(
152            "workspace",
153            format!("{} exists but is not a directory", workspace_dir.display()),
154        ),
155        Err(e) => CheckResult::fail(
156            "workspace",
157            format!("{} (error: {e})", workspace_dir.display()),
158        ),
159    }
160}
161
162fn check_sqlite(workspace_dir: &Path) -> CheckResult {
163    let db_path = workspace_dir.join("memory.db");
164    match rusqlite::Connection::open(&db_path) {
165        Ok(conn) => match conn.execute_batch("SELECT 1") {
166            Ok(()) => CheckResult::pass("sqlite", "memory.db opens and responds"),
167            Err(e) => CheckResult::fail("sqlite", format!("query failed: {e}")),
168        },
169        Err(e) => CheckResult::fail("sqlite", format!("cannot open memory.db: {e}")),
170    }
171}
172
173fn check_model_provider_registry() -> CheckResult {
174    let model_providers = crate::providers::list_model_providers();
175    if model_providers.is_empty() {
176        CheckResult::fail("model_providers", "no model providers registered")
177    } else {
178        CheckResult::pass(
179            "model_providers",
180            format!("{} model providers available", model_providers.len()),
181        )
182    }
183}
184
185fn check_tool_registry(config: &crate::config::Config) -> CheckResult {
186    // Probe one tool registry per enabled agent. V3 has no global default —
187    // tools are bound to a specific agent's risk profile.
188    let enabled_agents: Vec<&String> = config
189        .agents
190        .iter()
191        .filter(|(_, a)| a.enabled)
192        .map(|(alias, _)| alias)
193        .collect();
194    if enabled_agents.is_empty() {
195        return CheckResult::fail("tools", "no enabled agents configured");
196    }
197    let mut total_tools = 0usize;
198    for alias in &enabled_agents {
199        let security = match crate::security::SecurityPolicy::for_agent(config, alias) {
200            Ok(p) => std::sync::Arc::new(p),
201            Err(e) => return CheckResult::fail("tools", format!("agent {alias}: {e}")),
202        };
203        let tools = crate::tools::default_tools(security);
204        if tools.is_empty() {
205            return CheckResult::fail("tools", format!("agent {alias}: no tools registered"));
206        }
207        total_tools = tools.len();
208    }
209    CheckResult::pass(
210        "tools",
211        format!(
212            "{} enabled agent(s); {} core tools per registry",
213            enabled_agents.len(),
214            total_tools
215        ),
216    )
217}
218
219fn check_channel_config(config: &crate::config::Config) -> CheckResult {
220    let channels = zeroclaw_channels::listing::compiled_channels(&config.channels);
221    let configured = channels.iter().filter(|e| e.configured).count();
222    CheckResult::pass(
223        "channels",
224        format!(
225            "{} channel types, {} configured",
226            channels.len(),
227            configured
228        ),
229    )
230}
231
232fn check_security_policy(config: &crate::config::Config) -> CheckResult {
233    // Probe the security policy of every enabled agent. V3 binds policy
234    // to risk_profile per agent; there is no global "active" policy.
235    let enabled_agents: Vec<&String> = config
236        .agents
237        .iter()
238        .filter(|(_, a)| a.enabled)
239        .map(|(alias, _)| alias)
240        .collect();
241    if enabled_agents.is_empty() {
242        return CheckResult::fail("security", "no enabled agents configured");
243    }
244    let mut summaries = Vec::new();
245    for alias in &enabled_agents {
246        let Some(profile) = config.risk_profile_for_agent(alias) else {
247            return CheckResult::fail(
248                "security",
249                format!(
250                    "agents.{alias}.risk_profile does not name a configured risk_profiles entry"
251                ),
252            );
253        };
254        if let Err(e) = crate::security::SecurityPolicy::for_agent(config, alias) {
255            return CheckResult::fail("security", format!("agent {alias}: {e}"));
256        }
257        summaries.push(format!("{alias}={:?}", profile.level));
258    }
259    CheckResult::pass("security", summaries.join(", "))
260}
261
262fn check_version() -> CheckResult {
263    let version = env!("CARGO_PKG_VERSION");
264    CheckResult::pass("version", format!("v{version}"))
265}
266
267/// Flag `gateway.web_dist_dir` values that rely on shell-style expansion
268/// (a leading `~` or any `$VAR` / `${VAR}`). The gateway reads this field
269/// verbatim and never invokes a shell, so values like `~/web-dist` or
270/// `$HOME/web-dist` resolve to literal on-disk paths and silently fail to
271/// find the bundled assets — surface that here at `zeroclaw self-test`
272/// time instead of at runtime.
273///
274/// User-facing strings (check name + detail) go through Fluent
275/// (`cli-self-test-web-dist-dir-*` keys) per AGENTS.md § Localization —
276/// no bare Rust literals for CLI output. The check `name` field is
277/// `&'static str`, so we resolve the Fluent string once into a leaked
278/// static at first call. Reason phrases are Fluent keys too
279/// (`cli-web-dist-dir-reason-{tilde,dollar}`).
280fn check_web_dist_dir(config: &crate::config::Config) -> CheckResult {
281    let name = web_dist_dir_check_name();
282    match config.gateway.web_dist_dir.as_deref() {
283        None => CheckResult::pass(
284            name,
285            zeroclaw_runtime::i18n::get_required_cli_string(
286                "cli-self-test-web-dist-dir-pass-unset",
287            ),
288        ),
289        Some(value) => match web_dist_dir_expansion_reason_key(value) {
290            None => CheckResult::pass(
291                name,
292                zeroclaw_runtime::i18n::get_required_cli_string_with_args(
293                    "cli-self-test-web-dist-dir-pass-literal",
294                    &[("path", value)],
295                ),
296            ),
297            Some(reason_key) => {
298                let reason = zeroclaw_runtime::i18n::get_required_cli_string(reason_key);
299                CheckResult::fail(
300                    name,
301                    zeroclaw_runtime::i18n::get_required_cli_string_with_args(
302                        "cli-self-test-web-dist-dir-fail-expansion",
303                        &[("path", value), ("reason", reason.as_str())],
304                    ),
305                )
306            }
307        },
308    }
309}
310
311/// Resolve the localized check name once and cache it as a `&'static str`
312/// (CheckResult::name is `&'static str` to stay copyable across the table
313/// renderer). Falls back to the bare identifier if the Fluent string is
314/// missing (mirrors the `missing_cli_string` warn-log behavior).
315fn web_dist_dir_check_name() -> &'static str {
316    use std::sync::OnceLock;
317    static CACHED: OnceLock<&'static str> = OnceLock::new();
318    CACHED.get_or_init(|| {
319        let resolved =
320            zeroclaw_runtime::i18n::get_required_cli_string("cli-self-test-web-dist-dir-name");
321        Box::leak(resolved.into_boxed_str())
322    })
323}
324
325/// Return the Fluent reason key when `value` looks like it expects
326/// shell expansion the gateway will not perform. `None` means the value
327/// is a literal path that the gateway can resolve as-is.
328fn web_dist_dir_expansion_reason_key(value: &str) -> Option<&'static str> {
329    if value.starts_with('~') {
330        Some("cli-web-dist-dir-reason-tilde")
331    } else if value.contains('$') {
332        Some("cli-web-dist-dir-reason-dollar")
333    } else {
334        None
335    }
336}
337
338/// Resolve a wildcard bind address (`0.0.0.0`, `[::]`) to a concrete
339/// loopback target so the probe can actually connect — and report the
340/// configured value alongside so the user isn't confused about why the
341/// output says `127.0.0.1` when their `config.toml` says `0.0.0.0`
342///. Returns `(probe_host, display_host)` where `display_host`
343/// is `Some(_)` only when a rewrite happened.
344fn resolve_probe_host(configured: &str) -> (&str, Option<&str>) {
345    match configured {
346        "0.0.0.0" => ("127.0.0.1", Some("0.0.0.0")),
347        // Normalise both shapes to bracketed form for the display URL so the
348        // unbracketed `::` doesn't yield `http://:::42617` (three colons,
349        // invalid URL). The probe target stays `[::1]`.
350        "[::]" | "::" => ("[::1]", Some("[::]")),
351        other => (other, None),
352    }
353}
354
355fn format_probe_url(scheme: &str, configured_host: &str, port: u16, path: &str) -> String {
356    let (probe_host, display_host) = resolve_probe_host(configured_host);
357    let probed = format!("{scheme}://{probe_host}:{port}{path}");
358    match display_host {
359        Some(cfg) => {
360            format!("{scheme}://{cfg}:{port}{path} (probed via {scheme}://{probe_host}:{port})")
361        }
362        None => probed,
363    }
364}
365
366async fn check_gateway_health(config: &crate::config::Config) -> CheckResult {
367    let port = config.gateway.port;
368    let (probe_host, _) = resolve_probe_host(&config.gateway.host);
369    let probe_url = format!("http://{probe_host}:{port}/health");
370    let display_url = format_probe_url("http", &config.gateway.host, port, "/health");
371    match reqwest::Client::new()
372        .get(&probe_url)
373        .timeout(std::time::Duration::from_secs(5))
374        .send()
375        .await
376    {
377        Ok(resp) if resp.status().is_success() => {
378            CheckResult::pass("gateway", format!("health OK at {display_url}"))
379        }
380        Ok(resp) => CheckResult::fail("gateway", format!("health returned {}", resp.status())),
381        Err(e) => CheckResult::fail("gateway", format!("not reachable at {display_url}: {e}")),
382    }
383}
384
385async fn check_memory_roundtrip(config: &crate::config::Config) -> CheckResult {
386    let mem = match crate::memory::create_memory(&config.memory, &config.data_dir, None) {
387        Ok(m) => m,
388        Err(e) => return CheckResult::fail("memory", format!("cannot create backend: {e}")),
389    };
390
391    let test_key = "__selftest_probe__";
392    let test_value = "selftest_ok";
393
394    if let Err(e) = mem
395        .store(
396            test_key,
397            test_value,
398            crate::memory::MemoryCategory::Core,
399            None,
400        )
401        .await
402    {
403        return CheckResult::fail("memory", format!("write failed: {e}"));
404    }
405
406    match mem.recall(test_key, 1, None, None, None).await {
407        Ok(entries) if !entries.is_empty() => {
408            let _ = mem.forget(test_key).await;
409            CheckResult::pass("memory", "write/read/delete round-trip OK")
410        }
411        Ok(_) => {
412            let _ = mem.forget(test_key).await;
413            CheckResult::fail("memory", "no entries returned after round-trip")
414        }
415        Err(e) => {
416            let _ = mem.forget(test_key).await;
417            CheckResult::fail("memory", format!("read failed: {e}"))
418        }
419    }
420}
421
422#[cfg(feature = "gateway")]
423async fn check_websocket_handshake(config: &crate::config::Config) -> CheckResult {
424    let port = config.gateway.port;
425    let (probe_host, _) = resolve_probe_host(&config.gateway.host);
426    let probe_url = format!("ws://{probe_host}:{port}/ws/chat");
427    let display_url = format_probe_url("ws", &config.gateway.host, port, "/ws/chat");
428
429    match tokio_tungstenite::connect_async(&probe_url).await {
430        Ok((_, _)) => CheckResult::pass("websocket", format!("handshake OK at {display_url}")),
431        Err(e) => CheckResult::fail(
432            "websocket",
433            format!("handshake failed at {display_url}: {e}"),
434        ),
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::{format_probe_url, resolve_probe_host, web_dist_dir_expansion_reason_key};
441
442    #[test]
443    fn web_dist_dir_with_tilde_resolves_to_tilde_reason_key() {
444        // Issue #6079: `~/web-dist` is read verbatim and silently fails.
445        // #6961 Round 3: predicate now returns Fluent key, not bare phrase.
446        assert_eq!(
447            web_dist_dir_expansion_reason_key("~/web-dist"),
448            Some("cli-web-dist-dir-reason-tilde")
449        );
450        assert_eq!(
451            web_dist_dir_expansion_reason_key("~"),
452            Some("cli-web-dist-dir-reason-tilde")
453        );
454    }
455
456    #[test]
457    fn web_dist_dir_with_env_var_resolves_to_dollar_reason_key() {
458        // Issue #6079: `$HOME/web-dist` and `${HOME}/web-dist` are read verbatim.
459        assert_eq!(
460            web_dist_dir_expansion_reason_key("$HOME/web-dist"),
461            Some("cli-web-dist-dir-reason-dollar")
462        );
463        assert_eq!(
464            web_dist_dir_expansion_reason_key("${HOME}/web-dist"),
465            Some("cli-web-dist-dir-reason-dollar")
466        );
467        assert_eq!(
468            web_dist_dir_expansion_reason_key("/srv/$USER/dist"),
469            Some("cli-web-dist-dir-reason-dollar")
470        );
471        // Absolute and relative literal paths must NOT be flagged.
472        assert!(web_dist_dir_expansion_reason_key("/srv/zeroclaw/web-dist").is_none());
473        assert!(web_dist_dir_expansion_reason_key("./dist").is_none());
474    }
475
476    #[test]
477    fn check_web_dist_dir_emits_localized_fail_for_tilde() {
478        // #6961 Round 3: the failure detail goes through Fluent
479        // (cli-self-test-web-dist-dir-fail-expansion) — assert the
480        // resolved English string contains the inlined path + reason.
481        let mut config = crate::config::Config::default();
482        config.gateway.web_dist_dir = Some("~/web-dist".to_string());
483
484        let result = super::check_web_dist_dir(&config);
485        assert!(!result.passed, "tilde path must fail the check");
486
487        let expected_reason =
488            zeroclaw_runtime::i18n::get_required_cli_string("cli-web-dist-dir-reason-tilde");
489        let expected_detail = zeroclaw_runtime::i18n::get_required_cli_string_with_args(
490            "cli-self-test-web-dist-dir-fail-expansion",
491            &[("path", "~/web-dist"), ("reason", expected_reason.as_str())],
492        );
493        assert_eq!(result.detail, expected_detail);
494
495        let expected_name =
496            zeroclaw_runtime::i18n::get_required_cli_string("cli-self-test-web-dist-dir-name");
497        assert_eq!(result.name, expected_name.as_str());
498    }
499
500    #[test]
501    fn check_web_dist_dir_emits_localized_pass_for_literal() {
502        let mut config = crate::config::Config::default();
503        config.gateway.web_dist_dir = Some("/srv/zeroclaw/web-dist".to_string());
504
505        let result = super::check_web_dist_dir(&config);
506        assert!(result.passed);
507
508        let expected_detail = zeroclaw_runtime::i18n::get_required_cli_string_with_args(
509            "cli-self-test-web-dist-dir-pass-literal",
510            &[("path", "/srv/zeroclaw/web-dist")],
511        );
512        assert_eq!(result.detail, expected_detail);
513    }
514
515    #[test]
516    fn check_web_dist_dir_emits_localized_pass_when_unset() {
517        let config = crate::config::Config::default();
518        let result = super::check_web_dist_dir(&config);
519        assert!(result.passed);
520
521        let expected_detail = zeroclaw_runtime::i18n::get_required_cli_string(
522            "cli-self-test-web-dist-dir-pass-unset",
523        );
524        assert_eq!(result.detail, expected_detail);
525    }
526
527    #[test]
528    fn resolve_probe_host_ipv4_wildcard() {
529        assert_eq!(
530            resolve_probe_host("0.0.0.0"),
531            ("127.0.0.1", Some("0.0.0.0"))
532        );
533    }
534
535    #[test]
536    fn resolve_probe_host_ipv6_wildcard_bracketed() {
537        assert_eq!(resolve_probe_host("[::]"), ("[::1]", Some("[::]")));
538    }
539
540    #[test]
541    fn resolve_probe_host_ipv6_wildcard_unbracketed_normalises_to_brackets() {
542        // Regression: previously returned `Some("::")`, which `format_probe_url`
543        // would render as `http://:::42617/...` (three colons, invalid URL).
544        assert_eq!(resolve_probe_host("::"), ("[::1]", Some("[::]")));
545    }
546
547    #[test]
548    fn resolve_probe_host_concrete_host_passthrough() {
549        assert_eq!(resolve_probe_host("127.0.0.1"), ("127.0.0.1", None));
550        assert_eq!(
551            resolve_probe_host("example.internal"),
552            ("example.internal", None)
553        );
554    }
555
556    #[test]
557    fn format_probe_url_ipv4_wildcard_shows_both() {
558        assert_eq!(
559            format_probe_url("http", "0.0.0.0", 42617, "/health"),
560            "http://0.0.0.0:42617/health (probed via http://127.0.0.1:42617)"
561        );
562    }
563
564    #[test]
565    fn format_probe_url_ipv6_wildcard_unbracketed_shows_valid_url() {
566        // Regression: was `http://:::42617/health`, now `http://[::]:42617/health`.
567        assert_eq!(
568            format_probe_url("http", "::", 42617, "/health"),
569            "http://[::]:42617/health (probed via http://[::1]:42617)"
570        );
571    }
572
573    #[test]
574    fn format_probe_url_ipv6_wildcard_bracketed_shows_valid_url() {
575        assert_eq!(
576            format_probe_url("http", "[::]", 42617, "/health"),
577            "http://[::]:42617/health (probed via http://[::1]:42617)"
578        );
579    }
580
581    #[test]
582    fn format_probe_url_concrete_host_no_probe_suffix() {
583        assert_eq!(
584            format_probe_url("ws", "127.0.0.1", 42617, "/ws/chat"),
585            "ws://127.0.0.1:42617/ws/chat"
586        );
587    }
588}