openlatch-provider 0.0.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `doctor` — read-only multi-check that surfaces likely misconfigurations
//! before they bite the user in production.
//!
//! Always exits 0 — issues are *reported*, never propagated as a hard error
//! (per `.claude/rules/cli-output.md` idempotency table). Machines parse the
//! list under `--output json` / `--output sarif`; humans get a colorized
//! panel with per-row remediation hints.

use std::time::Duration;

use serde::Serialize;
use serde_json::json;

use crate::api::{bindings as api_bindings, probe as api_probe};
use crate::auth::binding_secrets::{
    default_file_dir, BindingSecretStore, FileBindingSecretStore, KeyringBindingSecretStore,
};
use crate::cli::commands::shared;
use crate::cli::GlobalArgs;
use crate::config;
use crate::error::OlError;
use crate::manifest;
use crate::ui::output::OutputConfig;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckLevel {
    Pass,
    Warn,
    Fail,
    Info,
}

#[derive(Debug, Clone, Serialize)]
pub struct CheckResult {
    pub category: String,
    pub name: String,
    pub level: CheckLevel,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub code: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub remediation: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub docs_url: Option<String>,
}

impl CheckResult {
    fn pass(category: &str, name: &str, message: impl Into<String>) -> Self {
        Self {
            category: category.into(),
            name: name.into(),
            level: CheckLevel::Pass,
            message: message.into(),
            code: None,
            remediation: None,
            docs_url: None,
        }
    }
    fn fail(category: &str, name: &str, err: &OlError) -> Self {
        Self {
            category: category.into(),
            name: name.into(),
            level: CheckLevel::Fail,
            message: err.message.clone(),
            code: Some(err.code.code.into()),
            remediation: err.suggestion.clone(),
            docs_url: Some(err.code.docs_url.into()),
        }
    }
    fn warn(category: &str, name: &str, message: impl Into<String>, code: Option<String>) -> Self {
        Self {
            category: category.into(),
            name: name.into(),
            level: CheckLevel::Warn,
            message: message.into(),
            code,
            remediation: None,
            docs_url: None,
        }
    }
    fn info(category: &str, name: &str, message: impl Into<String>) -> Self {
        Self {
            category: category.into(),
            name: name.into(),
            level: CheckLevel::Info,
            message: message.into(),
            code: None,
            remediation: None,
            docs_url: None,
        }
    }
}

pub async fn run(g: &GlobalArgs) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let mut results: Vec<CheckResult> = Vec::new();

    // ---- Auth ------------------------------------------------------------
    match shared::retrieve_token().await {
        Ok(_) => results.push(CheckResult::pass(
            "Authentication",
            "Token present",
            "Editor token loaded",
        )),
        Err(e) => results.push(CheckResult::fail("Authentication", "Token present", &e)),
    }

    let api_client = match shared::make_client().await {
        Ok(c) => Some(c),
        Err(e) => {
            results.push(CheckResult::fail("Authentication", "API client", &e));
            None
        }
    };

    // ---- Manifest --------------------------------------------------------
    let manifest_path = match config::active_manifest_path(g.profile.as_deref()) {
        Ok(p) => Some(p),
        Err(e) => {
            results.push(CheckResult::fail("Manifest", "Active manifest", &e));
            None
        }
    };
    let manifest_obj = manifest_path
        .as_ref()
        .and_then(|path| match manifest::load(path) {
            Ok(m) => {
                results.push(CheckResult::pass(
                    "Manifest",
                    "Schema valid",
                    format!("Loaded {}", path.display()),
                ));
                Some(m)
            }
            Err(e) => {
                results.push(CheckResult::fail("Manifest", "Schema valid", &e));
                None
            }
        });

    // ---- Live bindings + per-binding endpoint probe ----------------------
    if let (Some(client), Some(_)) = (api_client.as_ref(), manifest_obj.as_ref()) {
        match api_bindings::list(client).await {
            Ok(bindings) => {
                results.push(CheckResult::info(
                    "Bindings",
                    "Live count",
                    format!("Platform reports {} binding(s)", bindings.len()),
                ));
                for b in &bindings {
                    let detail = match api_bindings::get(client, &b.id).await {
                        Ok(d) => d,
                        Err(e) => {
                            results.push(CheckResult::fail(
                                "Bindings",
                                &format!("{} ({} / {})", b.id, b.tool, b.provider),
                                &e,
                            ));
                            continue;
                        }
                    };
                    let url = detail.endpoint_url.clone().unwrap_or_default();
                    if url.is_empty() {
                        results.push(CheckResult::warn(
                            "Bindings",
                            &b.id,
                            "binding has no endpoint_url",
                            None,
                        ));
                        continue;
                    }
                    match api_probe::probe(&url) {
                        Ok(report) => {
                            let summary = if report.findings.is_empty() {
                                "endpoint passed all SSRF + TLS + connect probes".to_string()
                            } else {
                                format!("{} findings", report.findings.len())
                            };
                            results.push(CheckResult::pass(
                                "Bindings",
                                &b.id,
                                format!("{url}{summary}"),
                            ));
                        }
                        Err(e) => {
                            results.push(CheckResult::fail("Bindings", &b.id, &e));
                        }
                    }
                }
            }
            Err(e) => results.push(CheckResult::fail("Bindings", "Live count", &e)),
        }
    }

    // ---- Local binding-secret store ---------------------------------------
    let primary = KeyringBindingSecretStore::new();
    let dir = default_file_dir(&config::provider_dir());
    let machine_id = config::machine_id_or_init().unwrap_or_else(|_| "unknown".into());
    let file = FileBindingSecretStore::new(dir, machine_id);
    let stored: Vec<String> = {
        let mut v = primary.list_known().unwrap_or_default();
        if let Ok(more) = file.list_known() {
            for id in more {
                if !v.contains(&id) {
                    v.push(id);
                }
            }
        }
        v
    };
    results.push(CheckResult::info(
        "Secrets",
        "Local store",
        format!("{} binding secret(s) stored locally", stored.len()),
    ));

    // ---- Listen daemon health probe -------------------------------------
    let listen_check = probe_local_listener().await;
    results.push(listen_check);

    // ---- Update channel (advisory) ---------------------------------------
    results.push(CheckResult::info(
        "Update",
        "Current version",
        env!("CARGO_PKG_VERSION"),
    ));

    // ---- Render ----------------------------------------------------------
    render(&out, &results);
    Ok(())
}

async fn probe_local_listener() -> CheckResult {
    let url = "http://127.0.0.1:8443/v1/health";
    let client = match reqwest::Client::builder()
        .timeout(Duration::from_secs(2))
        .build()
    {
        Ok(c) => c,
        Err(e) => {
            return CheckResult::info(
                "Listen daemon",
                "Health probe",
                format!("client build failed: {e}"),
            )
        }
    };
    match client.get(url).send().await {
        Ok(resp) if resp.status().is_success() => CheckResult::pass(
            "Listen daemon",
            "Health probe",
            "127.0.0.1:8443 /v1/health 200",
        ),
        Ok(resp) => CheckResult::warn(
            "Listen daemon",
            "Health probe",
            format!("returned HTTP {}", resp.status()),
            None,
        ),
        Err(_) => CheckResult::info(
            "Listen daemon",
            "Health probe",
            "not running on 127.0.0.1:8443 — start with `openlatch-provider listen --no-tls`",
        ),
    }
}

fn render(out: &OutputConfig, results: &[CheckResult]) {
    if out.is_machine() {
        match out.cli_format {
            crate::cli::OutputFormat::Sarif => {
                out.print_json(&render_sarif(results));
            }
            _ => out.print_json(&json!({ "checks": results })),
        }
        return;
    }

    println!();
    println!("Doctor — openlatch-provider {}", env!("CARGO_PKG_VERSION"));
    println!("───────────────────────────────────────────────────────────");
    let mut current_category = String::new();
    for r in results {
        if r.category != current_category {
            current_category = r.category.clone();
            println!("\n{}", current_category);
        }
        let mark = match r.level {
            CheckLevel::Pass => "",
            CheckLevel::Warn => "",
            CheckLevel::Fail => "",
            CheckLevel::Info => "",
        };
        println!("  {mark} {}{}", r.name, r.message);
        if let Some(code) = &r.code {
            if let Some(docs) = &r.docs_url {
                println!("      [{}] {}", code, docs);
            }
        }
        if let Some(rem) = &r.remediation {
            println!("{rem}");
        }
    }
    println!();
    let pass = results
        .iter()
        .filter(|r| r.level == CheckLevel::Pass)
        .count();
    let warn = results
        .iter()
        .filter(|r| r.level == CheckLevel::Warn)
        .count();
    let fail = results
        .iter()
        .filter(|r| r.level == CheckLevel::Fail)
        .count();
    let info = results
        .iter()
        .filter(|r| r.level == CheckLevel::Info)
        .count();
    println!("Summary: {pass} pass, {warn} warning, {fail} failure, {info} info");
}

fn render_sarif(results: &[CheckResult]) -> serde_json::Value {
    let rules: Vec<serde_json::Value> = results
        .iter()
        .filter_map(|r| {
            r.code.as_ref().map(|code| {
                json!({
                    "id": code,
                    "name": r.name,
                    "shortDescription": { "text": r.message },
                    "helpUri": r.docs_url,
                })
            })
        })
        .collect();
    let results_arr: Vec<serde_json::Value> = results
        .iter()
        .map(|r| {
            json!({
                "ruleId": r.code.clone().unwrap_or_else(|| "OL-INFO".into()),
                "level": match r.level {
                    CheckLevel::Pass => "none",
                    CheckLevel::Warn => "warning",
                    CheckLevel::Fail => "error",
                    CheckLevel::Info => "note",
                },
                "message": { "text": format!("{}{}", r.name, r.message) },
            })
        })
        .collect();
    json!({
        "version": "2.1.0",
        "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
        "runs": [{
            "tool": {
                "driver": {
                    "name": "openlatch-provider",
                    "version": env!("CARGO_PKG_VERSION"),
                    "informationUri": "https://openlatch.ai",
                    "rules": rules,
                }
            },
            "results": results_arr,
        }]
    })
}