openlatch-provider 0.2.2

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Localhost proxy — forwards verified events to the vendor's tool server.
//!
//! Failure-mode mapping (Non-Negotiable per `.claude/rules/architecture.md`):
//!
//! | Outcome                                  | Code      | What we send back to platform |
//! |------------------------------------------|-----------|-------------------------------|
//! | Connect refused / DNS failure            | OL-4224   | 502 |
//! | Tool returned 5xx (or non-2xx)           | OL-4225   | 502 |
//! | Verdict body > 250 KB                    | OL-4223   | 502 |
//! | Wall-clock budget exhausted              | OL-4228   | 504 |
//!
//! Per-runtime singleton `reqwest::Client` reused across all requests so the
//! TCP/TLS pool stays warm. We deliberately disable redirect-following on the
//! localhost call — the tool server should respond directly, never bounce us
//! anywhere else (potential SSRF surface).

use std::time::Duration;

use bytes::Bytes;
use reqwest::redirect::Policy;
use reqwest::Client;

use crate::error::{
    OlError, OL_4223_VERDICT_TOO_LARGE, OL_4224_TOOL_UNREACHABLE, OL_4225_TOOL_5XX,
    OL_4228_DEADLINE_EXCEEDED,
};
use crate::runtime::deadline::Deadline;
use crate::runtime::multi_tool::LocalRoute;

pub const MAX_VERDICT_BYTES: usize = 250 * 1024;

/// Build the shared HTTP client used to call vendor tool servers.
///
/// `pool_idle_timeout(90s)` matches typical reverse-proxy idle defaults so
/// servers behind nginx/Caddy don't drop our connection mid-pool. `redirect`
/// is hard-off — see module docs.
pub fn build_proxy_client() -> Result<Client, OlError> {
    Client::builder()
        .pool_idle_timeout(Duration::from_secs(90))
        .pool_max_idle_per_host(32)
        .redirect(Policy::none())
        // We call localhost so TLS is rare; rustls is still required by the
        // crate-wide invariant. `default-features = false` in Cargo.toml
        // already strips OpenSSL.
        .build()
        .map_err(|e| OlError::new(OL_4224_TOOL_UNREACHABLE, format!("reqwest builder: {e}")))
}

/// Per-call result of a localhost proxy call.
#[derive(Debug, Clone)]
pub struct ProxyOutcome {
    pub body: Bytes,
    pub tool_ms: u64,
}

/// Forward an event body to the vendor's tool. Honors the wall-clock deadline
/// and clamps the response size at 250 KB.
pub async fn proxy(
    client: &Client,
    route: &LocalRoute,
    body: Bytes,
    deadline: Deadline,
) -> Result<ProxyOutcome, OlError> {
    let started = std::time::Instant::now();
    let remaining = deadline.remaining()?;

    let request = client
        .post(&route.local_url)
        .header(reqwest::header::CONTENT_TYPE, "application/json")
        .body(body);

    let resp = match tokio::time::timeout(remaining, request.send()).await {
        Err(_) => {
            return Err(OlError::new(
                OL_4228_DEADLINE_EXCEEDED,
                "deadline elapsed waiting for localhost tool",
            ));
        }
        Ok(Err(e)) if e.is_connect() => {
            return Err(OlError::new(
                OL_4224_TOOL_UNREACHABLE,
                format!("connect to {}: {}", route.local_url, e),
            ));
        }
        Ok(Err(e)) if e.is_timeout() => {
            return Err(OlError::new(
                OL_4228_DEADLINE_EXCEEDED,
                format!("reqwest timeout: {e}"),
            ));
        }
        Ok(Err(e)) => {
            return Err(OlError::new(
                OL_4224_TOOL_UNREACHABLE,
                format!("reqwest error: {e}"),
            ));
        }
        Ok(Ok(r)) => r,
    };

    if !resp.status().is_success() {
        let status = resp.status();
        return Err(OlError::new(
            OL_4225_TOOL_5XX,
            format!("tool returned HTTP {status}"),
        ));
    }

    // Read the body — but enforce the size cap by inspecting Content-Length
    // first (so we can refuse a 100 MB monster without buffering it).
    if let Some(cl) = resp.content_length() {
        if cl as usize > MAX_VERDICT_BYTES {
            return Err(OlError::new(
                OL_4223_VERDICT_TOO_LARGE,
                format!("Content-Length {cl} > {MAX_VERDICT_BYTES} cap"),
            ));
        }
    }

    let bytes = match tokio::time::timeout(deadline.remaining()?, resp.bytes()).await {
        Err(_) => {
            return Err(OlError::new(
                OL_4228_DEADLINE_EXCEEDED,
                "deadline elapsed reading tool body",
            ));
        }
        Ok(Err(e)) => {
            return Err(OlError::new(
                OL_4225_TOOL_5XX,
                format!("read tool body: {e}"),
            ));
        }
        Ok(Ok(b)) => b,
    };

    if bytes.len() > MAX_VERDICT_BYTES {
        return Err(OlError::new(
            OL_4223_VERDICT_TOO_LARGE,
            format!("tool body {} bytes > {MAX_VERDICT_BYTES} cap", bytes.len()),
        ));
    }

    Ok(ProxyOutcome {
        body: bytes,
        tool_ms: started.elapsed().as_millis() as u64,
    })
}

/// Map a proxy error to its telemetry `failure_kind` tag.
pub fn telemetry_kind(err: &OlError) -> &'static str {
    match err.code.code {
        "OL-4223" => "oversize",
        "OL-4224" => "unreachable",
        "OL-4225" => "5xx",
        "OL-4228" => "timeout",
        _ => "unreachable",
    }
}