openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `trigger` — send a synthetic Standard Webhooks v1 signed event into a
//! locally-running `openlatch-provider listen` daemon.
//!
//! Targets the developer-loop pain point of "I can write a tool but I can't
//! exercise the runtime without a real platform deployment." The CLI signs
//! the synthetic request with the same `whsec_<base64>` the daemon holds, so
//! the daemon verifies and processes the event identically to one from
//! `api.openlatch.ai`.

use std::path::PathBuf;
use std::sync::Arc;
use std::time::SystemTime;

use clap::Args;
use secrecy::ExposeSecret;
use serde_json::json;

use crate::auth::binding_secrets::{
    default_file_dir, BindingSecretStore, FileBindingSecretStore, KeyringBindingSecretStore,
};
use crate::cli::GlobalArgs;
use crate::config;
use crate::error::{
    OlError, OL_4220_HMAC_FAILED, OL_4222_BINDING_NOT_CONFIGURED, OL_4224_TOOL_UNREACHABLE,
};
use crate::runtime::webhook;
use crate::ui::output::OutputConfig;

#[derive(Args, Debug)]
pub struct TriggerArgs {
    /// Event type — e.g. `pre_tool_use`, `before_shell_execution`.
    pub event_type: String,

    /// Binding id to target. Required when more than one binding is configured.
    #[arg(long, value_name = "ID")]
    pub binding: Option<String>,

    /// Tool name from the agent's perspective (e.g. `Bash`, `Read`).
    #[arg(long, value_name = "NAME")]
    pub tool: Option<String>,

    /// Tool-call input — JSON literal or plain string. Strings are wrapped
    /// as `{"command": "<value>"}` for shell-style hooks.
    #[arg(long, value_name = "JSON|STRING")]
    pub input: Option<String>,

    /// Listener port. Default 8443.
    #[arg(long, default_value_t = 8443)]
    pub port: u16,

    /// Listener host (default `127.0.0.1`).
    #[arg(long, default_value = "127.0.0.1", value_name = "ADDR")]
    pub host: String,

    /// Send plain HTTP rather than HTTPS. Matches `listen --no-tls`.
    #[arg(long)]
    pub no_tls: bool,

    /// Read the full event JSON from a file instead of building one from
    /// `--tool` / `--input`. Lets you replay payloads captured from prod.
    #[arg(long, value_name = "PATH")]
    pub from_file: Option<PathBuf>,

    /// Synthetic deadline budget (ms). Default 200.
    #[arg(long, default_value_t = 200)]
    pub deadline_ms: u64,

    /// Override the agent platform tag (default `claude-code`).
    #[arg(long, default_value = "claude-code", value_name = "PLATFORM")]
    pub agent: String,
}

pub async fn run(g: &GlobalArgs, args: TriggerArgs) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);

    // ---- 1. Build the event body --------------------------------------
    let body_bytes = if let Some(path) = &args.from_file {
        std::fs::read(path).map_err(|e| {
            OlError::new(
                crate::error::OL_4273_MANIFEST_UNREADABLE,
                format!("read {}: {e}", path.display()),
            )
        })?
    } else {
        build_event_body(&args)?
    };

    // ---- 2. Resolve binding id + load local secret --------------------
    let binding_id = args
        .binding
        .clone()
        .or_else(default_binding_from_manifest)
        .ok_or_else(|| {
            OlError::new(
                OL_4222_BINDING_NOT_CONFIGURED,
                "more than one binding configured locally — pass --binding=<id>",
            )
            .with_suggestion("List bindings: `openlatch-provider bindings list`")
        })?;
    let secret = load_secret(&binding_id)?;

    // ---- 3. Compute Standard Webhooks v1 framing ----------------------
    let webhook_id = format!("msg_{}", uuid::Uuid::now_v7().simple());
    let webhook_ts = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .map_err(|e| OlError::new(OL_4220_HMAC_FAILED, format!("clock: {e}")))?;
    let key_bytes = webhook::decode_secret(&secret)?;
    let sig_b64 = webhook::compute_signature(&key_bytes, &webhook_id, webhook_ts, &body_bytes);
    let signature_header = format!("v1,{sig_b64}");

    // ---- 4. POST to the listener --------------------------------------
    let scheme = if args.no_tls { "http" } else { "https" };
    let url = format!("{scheme}://{}:{}/v1/event", args.host, args.port);
    let event_id = format!("evt_{}", uuid::Uuid::now_v7().simple());

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(10))
        .danger_accept_invalid_certs(args.no_tls) // only for HTTP, no-op
        .build()
        .map_err(|e| OlError::new(OL_4224_TOOL_UNREACHABLE, format!("client: {e}")))?;
    let response = client
        .post(&url)
        .header("content-type", "application/json")
        .header("webhook-id", &webhook_id)
        .header("webhook-timestamp", webhook_ts.to_string())
        .header("webhook-signature", &signature_header)
        .header("X-OpenLatch-Provider-Id", "prv_local")
        .header("X-OpenLatch-Binding-Id", &binding_id)
        .header("X-OpenLatch-Event-Id", &event_id)
        .header("X-OpenLatch-Deadline-Ms", args.deadline_ms.to_string())
        .header("X-OpenLatch-Schema-Version", "1")
        .body(body_bytes.clone())
        .send()
        .await
        .map_err(|e| {
            OlError::new(OL_4224_TOOL_UNREACHABLE, format!("POST {url} failed: {e}"))
                .with_suggestion(format!(
                    "Is `openlatch-provider listen --port {} --no-tls` running?",
                    args.port
                ))
        })?;

    let status = response.status();
    let resp_headers = response.headers().clone();
    let resp_body = response
        .bytes()
        .await
        .map_err(|e| OlError::new(OL_4224_TOOL_UNREACHABLE, format!("read body: {e}")))?;

    // ---- 5. Render result ---------------------------------------------
    if out.is_machine() {
        let parsed: serde_json::Value =
            serde_json::from_slice(&resp_body).unwrap_or(serde_json::Value::Null);
        out.print_json(&json!({
            "status": status.as_u16(),
            "verdict": parsed,
            "headers": header_map_to_json(&resp_headers),
            "event_id": event_id,
            "binding_id": binding_id,
        }));
    } else if status.is_success() {
        out.print_step(&format!("Verdict received: HTTP {status}"));
        let pretty = serde_json::from_slice::<serde_json::Value>(&resp_body)
            .ok()
            .and_then(|v| serde_json::to_string_pretty(&v).ok())
            .unwrap_or_else(|| String::from_utf8_lossy(&resp_body).to_string());
        println!("{pretty}");
    } else {
        out.print_error(&OlError::new(
            OL_4220_HMAC_FAILED,
            format!(
                "listener returned HTTP {status}: {}",
                String::from_utf8_lossy(&resp_body)
            ),
        ));
    }

    if !status.is_success() {
        return Err(OlError::new(
            OL_4220_HMAC_FAILED,
            format!("listener rejected event ({status})"),
        ));
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn build_event_body(args: &TriggerArgs) -> Result<Vec<u8>, OlError> {
    let tool_name = args.tool.as_deref().unwrap_or("Bash");
    let tool_input = match args.input.as_deref() {
        Some(raw) => match serde_json::from_str::<serde_json::Value>(raw) {
            Ok(v) => v,
            Err(_) => json!({ "command": raw }),
        },
        None => json!({}),
    };
    let body = json!({
        "schema_version": 1,
        "event_id": format!("evt_{}", uuid::Uuid::now_v7().simple()),
        "org_id": "org_local",
        "agent": { "platform": args.agent, "version": "0.0.0" },
        "event_type": args.event_type,
        "tool_call": { "name": tool_name, "input": tool_input },
        "request": {
            "categories_requested": ["pii_outbound"],
            "latency_budget_ms": args.deadline_ms,
            "execution_mode": "sync"
        },
        "redaction_applied": {
            "was_redacted": false,
            "redactors_run": []
        }
    });
    serde_json::to_vec(&body).map_err(|e| OlError::new(OL_4220_HMAC_FAILED, format!("json: {e}")))
}

fn default_binding_from_manifest() -> Option<String> {
    let path = config::active_manifest_path(None).ok()?;
    let manifest = crate::manifest::load(&path).ok()?;
    let bindings = manifest.bindings.iter().collect::<Vec<_>>();
    if bindings.len() == 1 {
        // Caller still has to crosswalk to a bnd_… id via bindings list, but
        // for the single-binding case we can hint at the (tool, provider)
        // pair via a synthesized id used by tests. Real users will pass
        // --binding=<id> explicitly.
        Some(format!("bnd_{}_{}", bindings[0].tool, bindings[0].provider))
    } else {
        None
    }
}

fn load_secret(binding_id: &str) -> Result<secrecy::SecretString, OlError> {
    let primary: Arc<dyn BindingSecretStore> = Arc::new(KeyringBindingSecretStore::new());
    if let Ok(s) = primary.retrieve(binding_id) {
        return Ok(s);
    }
    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);
    file.retrieve(binding_id).map_err(|e| {
        OlError::new(
            OL_4222_BINDING_NOT_CONFIGURED,
            format!("no local secret for `{binding_id}`: {}", e.message),
        )
        .with_suggestion(format!(
            "Mint one: `openlatch-provider bindings rotate-secret {binding_id}`"
        ))
    })
}

fn header_map_to_json(headers: &reqwest::header::HeaderMap) -> serde_json::Value {
    let mut map = serde_json::Map::new();
    for (k, v) in headers {
        if let Ok(s) = v.to_str() {
            map.insert(k.as_str().to_string(), serde_json::Value::String(s.into()));
        }
    }
    serde_json::Value::Object(map)
}

#[allow(dead_code)]
fn _unused_expose_to_silence_warning(s: secrecy::SecretString) -> String {
    s.expose_secret().to_string()
}