newt-agent 0.7.5

Newt-Agent — free, friendly, local agentic coder (vi to Hermes's emacs)
//! `newt doctor` — health-check local backends and provider plugins.

use newt_core::dgx::{DgxConfig, EndpointKind};
use newt_core::Config;
use newt_inference::local::LocalOllamaBackend;
use std::path::Path;

pub async fn run(config_path: Option<&Path>) -> anyhow::Result<()> {
    println!("newt doctor — checking backends\n");

    let config = match config_path {
        Some(p) => Config::load(p)?,
        None => Config::resolve()?,
    };

    println!("Configured backends:");
    for backend in &config.backends {
        // #1212: probe by the backend's declared KIND via the BackendApi trait
        // — an openai/vLLM backend answers `/v1/models`, not Ollama's
        // `/api/tags`, and probing the wrong surface reported healthy
        // endpoints as `HTTP 404`. The kind knowledge lives in `api_for`,
        // not here (three-Cs).
        let status = probe_configured_backend(backend).await;
        println!(
            "  {} ({}, {}) — {status}",
            backend.name,
            backend.endpoint,
            backend.kind_label()
        );
    }

    println!("\nConfigured providers:");
    if config.providers.is_empty() {
        println!("  (none)");
    }
    for provider in &config.providers {
        let status = probe_provider(&provider.command);
        println!(
            "  {} (command: {}) — {status}",
            provider.name, provider.command
        );
    }

    // DGX nodes from [dgx] config section.
    println!("\nDGX nodes:");
    match &config.dgx {
        None => println!("  (none configured)"),
        Some(dgx) => probe_dgx(dgx).await,
    }

    // Also try endpoint discovery.
    println!("\nEndpoint discovery:");
    match LocalOllamaBackend::discover("default").await {
        Ok(backend) => println!("  Ollama: reachable at {}", backend.endpoint()),
        Err(e) => println!("  Ollama: {e}"),
    }

    // Discovered MCP servers — newt's own `[[mcp_servers]]` merged with the
    // servers already configured for Claude Code (~/.claude.json + ./.mcp.json),
    // so you can confirm newt sees the same set without re-configuring anything.
    // Shell engine + OCAP posture (#868 / #926): which engine parses run_command
    // (L2) and which kernel backend fences it (L3) — separate axes.
    println!("\nShell engine (OCAP):");
    let (backend, l3_active) = newt_core::ocap_l3_backend();
    match config.shell.as_ref().and_then(|s| s.engine) {
        Some(engine) => println!("  configured engine (L2): {engine} (explicit [shell] engine)"),
        None => {
            // #1243 Leg 1: the confined default is L3-gated and resolved
            // per-dispatch — report what THIS host resolves to right now, not a
            // stale hardcoded default. Keyed off the RESOLVED engine (not the
            // raw fence bit) so the reason is consistent on platforms where the
            // brush flip is not enabled (e.g. Windows keeps safe-subset).
            let resolved = newt_core::resolved_confined_default();
            let why = if resolved == newt_core::ShellEngine::Brush {
                "kernel fence enforcing — brush confines dynamic constructs"
            } else {
                "no per-run kernel fence here — safe-subset refuses dynamic constructs"
            };
            println!(
                "  confined default (L2): {resolved} — L3-gated, resolved per run_command ({why})"
            );
        }
    }
    println!("    · safe-subset — refuses $(...)/dynamic constructs (portable default)");
    println!(
        "    · host        — real /bin/sh in the kernel jail (full grammar; --full-access auto-selects)"
    );
    println!(
        "    · brush       — carried bash-in-Rust + L2 interceptor (cross-platform; confines restricted exec too; Windows full-access default)"
    );
    println!("  override per-run: --shell-engine <safe-subset|host|brush>");
    println!(
        "  L3 kernel jail (this platform): {backend}{}",
        if l3_active {
            "available"
        } else {
            "NOT available → a restricted fs grant runs advisory-only (sandbox_kind=None)"
        }
    );
    println!(
        "  agent-bridle attenuates your full ambient authority into structural OCAP grants; \
         --full-access temporarily lifts them."
    );

    println!("\nMCP servers (newt config + Claude Code config):");
    let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
    let workspace = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
    let mcp_toml = newt_core::Config::user_config_dir().map(|d| d.join("mcp.toml"));
    let servers = newt_core::mcp::discover(
        &config.mcp_servers,
        mcp_toml.as_deref(),
        home.as_deref(),
        &workspace,
    );
    if servers.is_empty() {
        println!("  (none discovered)");
    }
    // The leash `doctor` spawns stdio servers under — the SAME confinement the
    // live session applies, derived from the operator's configured preset (else a
    // read-only default), NEVER `Caveats::top()` (#94: no top() in a dispatch
    // path). So the diagnostic shows each server under its real confinement.
    // Shared with `newt mcp probe` (#1292) via Config::mcp_probe_caveats.
    let mcp_caveats = config.mcp_probe_caveats(&workspace);
    // For stdio servers we actually CONNECT (spawn + initialize + tools/list)
    // so you can see whether each is reachable and which tools it offers.
    for s in &servers {
        match s.transport {
            newt_core::mcp::TransportKind::Stdio => {
                match newt_mcp_client::connect_stdio(s, &mcp_caveats).await {
                    Ok(connected) => {
                        let names: Vec<&str> =
                            connected.tools.iter().map(|t| t.name.as_str()).collect();
                        let list = if names.is_empty() {
                            "(none)".to_string()
                        } else {
                            names.join(", ")
                        };
                        println!("  {} [stdio] — OK, {} tool(s): {list}", s.name, names.len());
                    }
                    Err(e) => println!("  {} [stdio] — ERROR: {e}", s.name),
                }
            }
            newt_core::mcp::TransportKind::Sse | newt_core::mcp::TransportKind::Http => {
                let kind = if matches!(s.transport, newt_core::mcp::TransportKind::Sse) {
                    "sse"
                } else {
                    "http"
                };
                let url = s.url.clone().unwrap_or_default();
                println!(
                    "  {} [{kind}] — {url} (skipped: only stdio is supported in this build)",
                    s.name
                );
            }
        }
    }

    Ok(())
}

async fn probe_dgx(dgx: &DgxConfig) {
    let active_node_name = dgx.active_node.as_deref();
    let active_endpoint = dgx.active_endpoint;
    let active_model = dgx.active_model.as_deref().unwrap_or("(none)");

    if dgx.nodes.is_empty() {
        println!("  (no nodes — using env overrides only)");
    }

    for node in &dgx.nodes {
        let is_active_node = active_node_name.map_or(dgx.nodes.len() == 1, |n| n == node.name);
        let node_marker = if is_active_node { " [active node]" } else { "" };
        println!("  {}{node_marker}", node.name);

        for kind in EndpointKind::ALL {
            let Some(url) = node.endpoint(kind) else {
                continue;
            };
            let is_active_ep = is_active_node && kind == active_endpoint;
            let active_marker = if is_active_ep { " *" } else { "" };
            let status = if kind.is_openai_compatible() {
                probe_vllm(url).await
            } else {
                probe_backend(url).await
            };
            println!("    {kind} ({url}){active_marker}{status}");
        }
    }

    // Resolve and show the active endpoint URL (may come from env vars too).
    match dgx.resolve_endpoint() {
        Ok(url) => println!("  Active: {active_model} @ {active_endpoint}{url}"),
        Err(e) => println!("  Active endpoint: unresolved ({e})"),
    }
}

/// Probe a `[[backends]]` entry the way a session would reach it (#1212):
/// route by `kind` through [`newt_core::backend_probe::api_for`] — the same
/// list-models call session-start adoption makes, with the same auth. When
/// `kind` is unset, race protocols via [`newt_core::backend_probe::detect_endpoint`].
/// An `embedded` backend has no endpoint to probe; report it as in-process.
async fn probe_configured_backend(backend: &newt_core::config::BackendConfig) -> String {
    if backend.kind == Some(newt_core::config::BackendKind::Embedded) {
        return "in-process (embedded — no endpoint to probe)".to_string();
    }
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(2))
        .build()
        .unwrap();
    let api_key = backend.resolve_api_key();
    if backend.needs_kind_probe() {
        return match newt_core::backend_probe::detect_endpoint(
            &client,
            &backend.endpoint,
            api_key.as_deref(),
        )
        .await
        {
            Ok(probe) => format!(
                "OK (detected {}, {} model(s) served)",
                probe.kind.label(),
                probe.models.len()
            ),
            Err(e) if e.to_string().starts_with("HTTP ") => e.to_string(),
            Err(e) => format!("unreachable: {e}"),
        };
    }
    let kind = backend.kind.expect("needs_kind_probe was false");
    match newt_core::backend_probe::api_for(kind)
        .list_models(&client, &backend.endpoint, api_key.as_deref())
        .await
    {
        Ok(models) => format!("OK ({} model(s) served)", models.len()),
        // The fetchers bail with `HTTP <status>` for a reachable-but-erroring
        // endpoint — keep that distinct from a connection failure.
        Err(e) if e.to_string().starts_with("HTTP ") => e.to_string(),
        Err(e) => format!("unreachable: {e}"),
    }
}

async fn probe_vllm(endpoint: &str) -> String {
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(2))
        .build()
        .unwrap();
    let url = format!("{}/v1/models", endpoint.trim_end_matches('/'));
    match client.get(&url).send().await {
        Ok(resp) if resp.status().is_success() => "OK".to_string(),
        Ok(resp) => format!("HTTP {}", resp.status()),
        Err(e) => format!("unreachable: {e}"),
    }
}

async fn probe_backend(endpoint: &str) -> String {
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(2))
        .build()
        .unwrap();
    let url = format!("{}/api/tags", endpoint.trim_end_matches('/'));
    match client.get(&url).send().await {
        Ok(resp) if resp.status().is_success() => "OK".to_string(),
        Ok(resp) => format!("HTTP {}", resp.status()),
        Err(e) => format!("unreachable: {e}"),
    }
}

fn probe_provider(command: &str) -> &'static str {
    let status = std::process::Command::new(command)
        .arg("--version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status();
    match status {
        Ok(s) if s.success() => "found on PATH",
        Ok(_) => "found but exited with error",
        Err(_) => "not found on PATH",
    }
}

/// `newt doctor --sign-ocap` (#1207): the blessing ceremony for
/// `~/.newt/ocap/approve.toml`. A PRESENT human running this command is
/// explicitly vouching for the file as it stands — every entry is re-signed
/// with the operator's root key (the same newt-identity root the session's
/// operating authority is minted from), which is the ONLY way hand-edited
/// entries become valid durable grants. High-danger targets (interpreters,
/// broad fs roots — the production danger table's judgment) are REFUSED a
/// signature, per the contract's mandatory `validate_approve` check, and are
/// reported so the operator can move them to `passkey.toml` or delete them.
///
/// Returns the process exit code: 0 = blessed (or nothing to sign); 2 = one
/// or more entries were refused (the file was still written — valid entries
/// are blessed, refused ones stay unsigned and will drop at load,
/// fail-closed). Errors bubble as `Err` (exit 1).
pub fn sign_ocap() -> anyhow::Result<i32> {
    use newt_core::ocap_store::{self, PolicyFile, Verdict};

    let Some(config_path) = newt_core::Config::user_config_path() else {
        anyhow::bail!("cannot resolve the user config directory (~/.newt)");
    };
    let approve_path = config_path
        .with_file_name("ocap")
        .join(Verdict::Approve.filename());
    let text = match std::fs::read_to_string(&approve_path) {
        Ok(t) => t,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            println!("nothing to sign: {} does not exist", approve_path.display());
            return Ok(0);
        }
        Err(e) => anyhow::bail!("cannot read {}: {e}", approve_path.display()),
    };
    let mut file = PolicyFile::parse(&text).map_err(|e| anyhow::anyhow!(e))?;

    let key_path = newt_identity::default_key_path()?;
    let user = newt_identity::load_or_generate(&key_path)?;

    let (signed, refused) = ocap_store::sign_approves(
        &mut file,
        newt_tui::ocap_high_danger_predicate(),
        |payload| user.sign(payload).to_bytes(),
    );
    std::fs::write(
        &approve_path,
        file.to_toml().map_err(|e| anyhow::anyhow!(e))?,
    )
    .map_err(|e| anyhow::anyhow!("cannot write {}: {e}", approve_path.display()))?;

    println!(
        "blessed {}: {signed} entr{} signed with the root key ({})",
        approve_path.display(),
        if signed == 1 { "y" } else { "ies" },
        key_path.display()
    );
    for r in &refused {
        println!("  REFUSED: {r}");
    }

    // Prove the result the way the session will see it: reload through the
    // same verify-at-load path and report the live durable grants.
    let (set, warnings) = ocap_store::load_store(&config_path, Some(user.public().as_bytes()));
    for w in &warnings {
        println!("  load warning: {w}");
    }
    let live = set
        .files
        .get(&Verdict::Approve)
        .map(|f| f.exec.len() + f.fs.len() + f.net.len())
        .unwrap_or(0);
    println!("verified: {live} durable grant(s) live at load");

    Ok(if refused.is_empty() { 0 } else { 2 })
}