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 {
pub event_type: String,
#[arg(long, value_name = "ID")]
pub binding: Option<String>,
#[arg(long, value_name = "NAME")]
pub tool: Option<String>,
#[arg(long, value_name = "JSON|STRING")]
pub input: Option<String>,
#[arg(long, default_value_t = 8443)]
pub port: u16,
#[arg(long, default_value = "127.0.0.1", value_name = "ADDR")]
pub host: String,
#[arg(long)]
pub no_tls: bool,
#[arg(long, value_name = "PATH")]
pub from_file: Option<PathBuf>,
#[arg(long, default_value_t = 200)]
pub deadline_ms: u64,
#[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);
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)?
};
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)?;
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}");
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) .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}")))?;
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(())
}
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 {
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()
}