mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
* refactor: make src/llm/ self-contained for crate extraction Move LlmError, LLM config types, and OAuth callback helpers into src/llm/ so the module has zero `use crate::` imports outside of crate::llm. This prepares the module for extraction into a standalone workspace crate. - Move LlmError enum from src/error.rs to src/llm/error.rs - Move LlmConfig, NearAiConfig, RegistryProviderConfig, BedrockConfig, CacheRetention, OAUTH_PLACEHOLDER from src/config/llm.rs to src/llm/config.rs - Move OAuth callback utilities (callback_url, bind_callback_listener, wait_for_callback, landing_html, etc.) from src/cli/oauth_defaults.rs to src/llm/oauth_helpers.rs - Remove session.rs dependency on crate::bootstrap (inline default path) - Add cache_retention field to RegistryProviderConfig, resolve from env in config/llm.rs instead of reading env var in llm/mod.rs - Add Check 6 to scripts/check-boundaries.sh enforcing LLM isolation - All original locations re-export for backward compatibility [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #767 review — session path bug and boundary check 1. Fix SessionConfig::default() usage in setup wizard: the fallback at wizard.rs:995 now constructs SessionConfig with the real default_session_path() instead of a relative "session.json", which would write auth tokens to the CWD instead of ~/.ironclaw/. 2. Widen check-boundaries.sh Check 6 to catch all `crate::` references (not just `use crate::` imports). Pre-existing inline references (16 occurrences) are reported as warnings; only new `use crate::` imports are hard violations. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #767 review and audit findings in src/llm/ PR review fixes: - Reject wildcard addresses (0.0.0.0, ::) in OAuth callback listener to prevent session token exposure on all interfaces - Fix boundary check comment-stripping that could hide real violations (use sed to strip inline comments before matching) Audit fixes: - Fix UTF-8 byte-index slicing panic in recording.rs hint extraction - Add effective_model_name() delegation to RetryProvider and SmartRoutingProvider for consistency with other wrappers - Add calculate_cost() delegation to CachedProvider and RecordingLlm - Deduplicate retry loop logic in RetryProvider via generic helper - Replace hardcoded /tmp path in recording tests with tempfile Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
284 lines
8.3 KiB
Rust
284 lines
8.3 KiB
Rust
//! `ironclaw doctor` - active health diagnostics.
|
|
//!
|
|
//! Probes external dependencies and validates configuration to surface
|
|
//! problems before they bite during normal operation. Each check reports
|
|
//! pass/fail with actionable guidance on failures.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use crate::bootstrap::ironclaw_base_dir;
|
|
|
|
/// Run all diagnostic checks and print results.
|
|
pub async fn run_doctor_command() -> anyhow::Result<()> {
|
|
println!("IronClaw Doctor");
|
|
println!("===============\n");
|
|
|
|
let mut passed = 0u32;
|
|
let mut failed = 0u32;
|
|
|
|
// ── Configuration checks ──────────────────────────────────
|
|
|
|
check(
|
|
"NEAR AI session",
|
|
check_nearai_session().await,
|
|
&mut passed,
|
|
&mut failed,
|
|
);
|
|
|
|
check(
|
|
"Database backend",
|
|
check_database().await,
|
|
&mut passed,
|
|
&mut failed,
|
|
);
|
|
|
|
check(
|
|
"Workspace directory",
|
|
check_workspace_dir(),
|
|
&mut passed,
|
|
&mut failed,
|
|
);
|
|
|
|
// ── External binary checks ────────────────────────────────
|
|
|
|
check(
|
|
"Docker",
|
|
check_binary("docker", &["--version"]),
|
|
&mut passed,
|
|
&mut failed,
|
|
);
|
|
|
|
check(
|
|
"cloudflared",
|
|
check_binary("cloudflared", &["--version"]),
|
|
&mut passed,
|
|
&mut failed,
|
|
);
|
|
|
|
check(
|
|
"ngrok",
|
|
check_binary("ngrok", &["version"]),
|
|
&mut passed,
|
|
&mut failed,
|
|
);
|
|
|
|
check(
|
|
"tailscale",
|
|
check_binary("tailscale", &["version"]),
|
|
&mut passed,
|
|
&mut failed,
|
|
);
|
|
|
|
// ── Summary ───────────────────────────────────────────────
|
|
|
|
println!();
|
|
println!(" {passed} passed, {failed} failed");
|
|
|
|
if failed > 0 {
|
|
println!("\n Some checks failed. This is normal if you don't use those features.");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ── Individual checks ───────────────────────────────────────
|
|
|
|
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32) {
|
|
match result {
|
|
CheckResult::Pass(detail) => {
|
|
*passed += 1;
|
|
println!(" [pass] {name}: {detail}");
|
|
}
|
|
CheckResult::Fail(detail) => {
|
|
*failed += 1;
|
|
println!(" [FAIL] {name}: {detail}");
|
|
}
|
|
CheckResult::Skip(reason) => {
|
|
println!(" [skip] {name}: {reason}");
|
|
}
|
|
}
|
|
}
|
|
|
|
enum CheckResult {
|
|
Pass(String),
|
|
Fail(String),
|
|
Skip(String),
|
|
}
|
|
|
|
async fn check_nearai_session() -> CheckResult {
|
|
// Check if session file exists
|
|
let session_path = crate::config::llm::default_session_path();
|
|
if !session_path.exists() {
|
|
// Check for API key mode
|
|
if std::env::var("NEARAI_API_KEY").is_ok() {
|
|
return CheckResult::Pass("API key configured".into());
|
|
}
|
|
return CheckResult::Fail(format!(
|
|
"session file not found at {}. Run `ironclaw onboard`",
|
|
session_path.display()
|
|
));
|
|
}
|
|
|
|
// Verify the session file is readable and non-empty
|
|
match std::fs::read_to_string(&session_path) {
|
|
Ok(content) if content.trim().is_empty() => {
|
|
CheckResult::Fail("session file is empty".into())
|
|
}
|
|
Ok(_) => CheckResult::Pass(format!("session found ({})", session_path.display())),
|
|
Err(e) => CheckResult::Fail(format!("cannot read session file: {e}")),
|
|
}
|
|
}
|
|
|
|
async fn check_database() -> CheckResult {
|
|
let backend = std::env::var("DATABASE_BACKEND")
|
|
.ok()
|
|
.unwrap_or_else(|| "postgres".into());
|
|
|
|
match backend.as_str() {
|
|
"libsql" | "turso" | "sqlite" => {
|
|
let path = std::env::var("LIBSQL_PATH")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|_| crate::config::default_libsql_path());
|
|
|
|
if path.exists() {
|
|
CheckResult::Pass(format!("libSQL database exists ({})", path.display()))
|
|
} else {
|
|
CheckResult::Pass(format!(
|
|
"libSQL database not found at {} (will be created on first run)",
|
|
path.display()
|
|
))
|
|
}
|
|
}
|
|
_ => {
|
|
if std::env::var("DATABASE_URL").is_ok() {
|
|
// Try to connect
|
|
match try_pg_connect().await {
|
|
Ok(()) => CheckResult::Pass("PostgreSQL connected".into()),
|
|
Err(e) => CheckResult::Fail(format!("PostgreSQL connection failed: {e}")),
|
|
}
|
|
} else {
|
|
CheckResult::Fail("DATABASE_URL not set".into())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "postgres")]
|
|
async fn try_pg_connect() -> Result<(), String> {
|
|
let url = std::env::var("DATABASE_URL").map_err(|_| "DATABASE_URL not set".to_string())?;
|
|
|
|
let config = deadpool_postgres::Config {
|
|
url: Some(url),
|
|
..Default::default()
|
|
};
|
|
let pool = crate::db::tls::create_pool(&config, crate::config::SslMode::from_env())
|
|
.map_err(|e| format!("pool error: {e}"))?;
|
|
|
|
let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get())
|
|
.await
|
|
.map_err(|_| "connection timeout (5s)".to_string())?
|
|
.map_err(|e| format!("{e}"))?;
|
|
|
|
client
|
|
.execute("SELECT 1", &[])
|
|
.await
|
|
.map_err(|e| format!("{e}"))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(feature = "postgres"))]
|
|
async fn try_pg_connect() -> Result<(), String> {
|
|
Err("postgres feature not compiled in".into())
|
|
}
|
|
|
|
fn check_workspace_dir() -> CheckResult {
|
|
let dir = ironclaw_base_dir();
|
|
|
|
if dir.exists() {
|
|
if dir.is_dir() {
|
|
CheckResult::Pass(format!("{}", dir.display()))
|
|
} else {
|
|
CheckResult::Fail(format!("{} exists but is not a directory", dir.display()))
|
|
}
|
|
} else {
|
|
CheckResult::Pass(format!("{} will be created on first run", dir.display()))
|
|
}
|
|
}
|
|
|
|
fn check_binary(name: &str, args: &[&str]) -> CheckResult {
|
|
match std::process::Command::new(name)
|
|
.args(args)
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.output()
|
|
{
|
|
Ok(output) => {
|
|
let version = String::from_utf8_lossy(&output.stdout);
|
|
let version = version.trim();
|
|
// Some tools print version to stderr
|
|
let version = if version.is_empty() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
stderr.trim().lines().next().unwrap_or("").to_string()
|
|
} else {
|
|
version.lines().next().unwrap_or("").to_string()
|
|
};
|
|
|
|
if output.status.success() {
|
|
CheckResult::Pass(version)
|
|
} else {
|
|
CheckResult::Fail(format!("exited with {}", output.status))
|
|
}
|
|
}
|
|
Err(_) => CheckResult::Skip(format!("{name} not found in PATH")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::cli::doctor::*;
|
|
|
|
#[test]
|
|
fn check_binary_finds_sh() {
|
|
match check_binary("sh", &["-c", "echo ok"]) {
|
|
CheckResult::Pass(_) => {}
|
|
other => panic!("expected Pass for sh, got: {}", format_result(&other)),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn check_binary_skips_nonexistent() {
|
|
match check_binary("__ironclaw_nonexistent_binary__", &["--version"]) {
|
|
CheckResult::Skip(_) => {}
|
|
other => panic!(
|
|
"expected Skip for nonexistent binary, got: {}",
|
|
format_result(&other)
|
|
),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn check_workspace_dir_does_not_panic() {
|
|
let result = check_workspace_dir();
|
|
match result {
|
|
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn check_nearai_session_does_not_panic() {
|
|
let result = check_nearai_session().await;
|
|
match result {
|
|
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
|
}
|
|
}
|
|
|
|
fn format_result(r: &CheckResult) -> String {
|
|
match r {
|
|
CheckResult::Pass(s) => format!("Pass({s})"),
|
|
CheckResult::Fail(s) => format!("Fail({s})"),
|
|
CheckResult::Skip(s) => format!("Skip({s})"),
|
|
}
|
|
}
|
|
}
|