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>,
}
fn which_simple(name: &str) -> Option<std::path::PathBuf> {
if name.contains(std::path::MAIN_SEPARATOR) || name.contains('/') || name.contains('\\') {
return None;
}
let exts: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".EXE;.CMD;.BAT".into())
.split(';')
.map(str::to_string)
.collect()
} else {
vec![String::new()]
};
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
for ext in &exts {
let cand: std::path::PathBuf = dir.join(format!("{name}{ext}"));
if cand.is_file() {
return Some(cand);
}
}
}
None
}
fn is_port_free(port: u16) -> bool {
std::net::TcpListener::bind(("127.0.0.1", port)).is_ok()
}
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();
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
}
};
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
}
});
if let (Some(path), Some(m)) = (manifest_path.as_ref(), manifest_obj.as_ref()) {
let manifest_dir = path.parent().unwrap_or(std::path::Path::new("."));
for binding in m.bindings.iter() {
let label = format!("{} / {}", binding.tool, binding.provider);
let spec = match crate::runtime::supervisor::ProcessSpec::from_manifest(
"doctor-preview", binding,
manifest_dir,
) {
Ok(s) => s,
Err(e) => {
results.push(CheckResult::fail("Process", &label, &e));
continue;
}
};
if !spec.cwd.exists() {
results.push(CheckResult::warn(
"Process",
&label,
format!("cwd does not exist: {}", spec.cwd.display()),
Some("OL-4300".into()),
));
}
let argv0 = &spec.command[0];
let argv0_exists = std::path::Path::new(argv0).exists();
let argv0_in_path = which_simple(argv0).is_some();
if !argv0_exists && !argv0_in_path {
results.push(CheckResult::warn(
"Process",
&label,
format!(
"command[0] `{argv0}` is not on PATH and does not exist relative to cwd"
),
Some("OL-4301".into()),
));
}
let port = spec.health.port;
if !is_port_free(port) {
results.push(CheckResult::warn(
"Process",
&label,
format!(
"port {port} is already bound on 127.0.0.1 — the daemon will refuse to start"
),
Some("OL-4304".into()),
));
} else {
results.push(CheckResult::pass(
"Process",
&label,
format!(
"spec valid, port {port} free, cwd {} ok",
spec.cwd.display()
),
));
}
}
}
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's provider 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)),
}
}
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()),
));
let listen_check = probe_local_listener().await;
results.push(listen_check);
results.push(CheckResult::info(
"Update",
"Current version",
env!("CARGO_PKG_VERSION"),
));
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,
}]
})
}