use std::io::Read;
use std::time::Duration;
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
use serde_json::{Value, json};
use crate::config::{CodingAgent, GatewayMode, HookForwardCommand};
use crate::error::CliError;
const HOOK_EVENTS: &[&str] = &[
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PermissionRequest",
"SubagentStart",
"SubagentStop",
"Notification",
"Stop",
"PreCompact",
"PostCompact",
"SessionEnd",
];
const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2);
const HERMES_HOOK_EVENTS: &[&str] = &[
"on_session_start",
"on_session_end",
"on_session_finalize",
"on_session_reset",
"pre_llm_call",
"post_llm_call",
"pre_api_request",
"post_api_request",
"api_request_error",
"pre_tool_call",
"post_tool_call",
"subagent_start",
"subagent_stop",
];
pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliError> {
validate_optional_json("session metadata", command.session_metadata.as_deref())?;
let input = read_hook_payload()?;
let Some(url) = hook_forward_url(&command)? else {
return Ok(());
};
let response = send_hook_forward_request(&command, url, input).await?;
handle_hook_forward_response(response, command.fail_closed).await
}
fn read_hook_payload() -> Result<String, CliError> {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
if input.trim().is_empty() {
Ok("{}".to_string())
} else {
Ok(input)
}
}
fn hook_forward_url(command: &HookForwardCommand) -> Result<Option<String>, CliError> {
let Some(gateway_url) = resolve_hook_gateway_url(
command.agent,
command.gateway_url.clone(),
std::env::var("NEMO_RELAY_GATEWAY_URL").ok(),
) else {
eprintln!(
"nemo-relay hook forward failed: missing gateway URL; pass --gateway-url or set NEMO_RELAY_GATEWAY_URL"
);
if command.fail_closed {
return Err(CliError::Install(
"missing gateway URL; pass --gateway-url or set NEMO_RELAY_GATEWAY_URL".into(),
));
}
return Ok(None);
};
Ok(Some(format!(
"{}{}",
gateway_url.trim_end_matches('/'),
command.agent.hook_path()
)))
}
async fn send_hook_forward_request(
command: &HookForwardCommand,
url: String,
input: String,
) -> Result<Result<reqwest::Response, reqwest::Error>, CliError> {
Ok(reqwest::Client::builder()
.timeout(HOOK_FORWARD_TIMEOUT)
.build()?
.post(url)
.headers(gateway_headers(
command.profile.as_deref(),
command.session_metadata.as_deref(),
command.gateway_mode,
)?)
.header(CONTENT_TYPE, "application/json")
.body(input)
.send()
.await)
}
async fn handle_hook_forward_response(
response: Result<reqwest::Response, reqwest::Error>,
fail_closed: bool,
) -> Result<(), CliError> {
match response {
Ok(response) => {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
if let Some(reason) = guardrail_rejection_reason(&body) {
return Err(CliError::GuardrailRejected(reason));
}
eprintln!("nemo-relay hook forward failed with HTTP {status}");
if fail_closed {
return Err(CliError::Install(format!(
"hook forward failed with HTTP {status}"
)));
}
return Ok(());
}
if !body.is_empty() {
println!("{body}");
}
Ok(())
}
Err(error) => {
eprintln!("nemo-relay hook forward failed: {error}");
if fail_closed {
Err(CliError::Upstream(error))
} else {
Ok(())
}
}
}
}
fn guardrail_rejection_reason(body: &str) -> Option<String> {
let value: Value = serde_json::from_str(body).ok()?;
let error = value.get("error")?;
(error.get("type").and_then(Value::as_str) == Some("nemo_relay_guardrail_rejected"))
.then(|| {
error
.get("reason")
.and_then(Value::as_str)
.or_else(|| error.get("message").and_then(Value::as_str))
.map(ToOwned::to_owned)
})
.flatten()
}
fn resolve_hook_gateway_url(
agent: CodingAgent,
command_url: Option<String>,
env_url: Option<String>,
) -> Option<String> {
match agent {
CodingAgent::Hermes => env_url.or(command_url),
_ => command_url.or(env_url),
}
}
pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value {
match agent {
CodingAgent::ClaudeCode => claude_hooks(command),
CodingAgent::Codex => codex_hooks(command),
CodingAgent::Hermes => hermes_hooks(command),
}
}
pub(crate) fn hook_forward_command(executable: &str, agent: CodingAgent) -> String {
format!("{executable} hook-forward {}", agent.as_arg())
}
fn claude_hooks(command: &str) -> Value {
hooks_for_events(HOOK_EVENTS, command, true)
}
fn codex_hooks(command: &str) -> Value {
hooks_for_events(HOOK_EVENTS, command, true)
}
pub(crate) fn hermes_hooks(command: &str) -> Value {
let hooks: serde_json::Map<String, Value> = HERMES_HOOK_EVENTS
.iter()
.map(|event| {
(
(*event).to_string(),
json!([{
"command": command,
"timeout": 30
}]),
)
})
.collect();
json!({ "hooks": Value::Object(hooks) })
}
fn hooks_for_events(events: &[&str], command: &str, matcher_for_tools: bool) -> Value {
let hooks: serde_json::Map<String, Value> = events
.iter()
.map(|event| {
let mut group = serde_json::Map::new();
if matcher_for_tools && event_matches_tools(event) {
group.insert("matcher".into(), json!("*"));
}
group.insert(
"hooks".into(),
json!([{
"type": "command",
"command": command,
"timeout": 30
}]),
);
(
(*event).to_string(),
Value::Array(vec![Value::Object(group)]),
)
})
.collect();
json!({ "hooks": Value::Object(hooks) })
}
fn event_matches_tools(event: &str) -> bool {
matches!(
event,
"PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "PermissionRequest"
)
}
pub(crate) fn merge_hooks(existing: Value, generated: Value) -> Result<Value, CliError> {
let mut root = hook_config_root(existing)?;
let hooks = hooks_object_mut(&mut root)?;
let generated_hooks = generated_hooks_object(&generated)?;
for (event, groups) in generated_hooks {
merge_event_hook_groups(hooks, event, groups)?;
}
Ok(root)
}
fn hook_config_root(existing: Value) -> Result<Value, CliError> {
match existing {
Value::Null => Ok(json!({})),
Value::Object(object) => Ok(Value::Object(object)),
_ => Err(CliError::Install(
"hook config must be a JSON object".into(),
)),
}
}
fn hooks_object_mut(root: &mut Value) -> Result<&mut serde_json::Map<String, Value>, CliError> {
root.as_object_mut()
.expect("root checked as object")
.entry("hooks")
.or_insert_with(|| json!({}))
.as_object_mut()
.ok_or_else(|| CliError::Install("hooks must be a JSON object".into()))
}
fn generated_hooks_object(generated: &Value) -> Result<&serde_json::Map<String, Value>, CliError> {
generated
.get("hooks")
.and_then(Value::as_object)
.ok_or_else(|| CliError::Install("generated hooks were malformed".into()))
}
fn merge_event_hook_groups(
hooks: &mut serde_json::Map<String, Value>,
event: &str,
groups: &Value,
) -> Result<(), CliError> {
let groups = groups
.as_array()
.ok_or_else(|| CliError::Install("generated hook groups were malformed".into()))?;
let event_groups = hooks.entry(event.to_string()).or_insert_with(|| json!([]));
let event_groups = event_groups
.as_array_mut()
.ok_or_else(|| CliError::Install(format!("{event} hooks must be an array")))?;
for group in groups {
if !event_groups.iter().any(|existing| existing == group) {
event_groups.push(group.clone());
}
}
Ok(())
}
pub(crate) fn merge_hermes_config(existing: &str, generated: Value) -> Result<String, CliError> {
let existing = if existing.trim().is_empty() {
Value::Null
} else {
serde_yaml::from_str(existing)
.map_err(|error| CliError::Install(format!("invalid YAML in Hermes config: {error}")))?
};
let merged = merge_hooks(existing, generated)?;
serde_yaml::to_string(&merged).map_err(|error| CliError::Install(error.to_string()))
}
fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliError> {
if let Some(value) = value {
serde_json::from_str::<Value>(value)
.map_err(|error| CliError::Install(format!("invalid {name}: {error}")))?;
}
Ok(())
}
fn gateway_headers(
profile: Option<&str>,
session_metadata: Option<&str>,
gateway_mode: Option<GatewayMode>,
) -> Result<HeaderMap, CliError> {
let mut headers = HeaderMap::new();
insert_header(&mut headers, "x-nemo-relay-config-profile", profile)?;
insert_header(
&mut headers,
"x-nemo-relay-session-metadata",
session_metadata,
)?;
insert_header(
&mut headers,
"x-nemo-relay-gateway-mode",
gateway_mode.map(GatewayMode::as_arg),
)?;
Ok(headers)
}
fn insert_header(
headers: &mut HeaderMap,
name: &'static str,
value: Option<&str>,
) -> Result<(), CliError> {
if let Some(value) = value {
headers.insert(
HeaderName::from_static(name),
HeaderValue::from_str(value)
.map_err(|error| CliError::Install(format!("invalid header {name}: {error}")))?,
);
}
Ok(())
}
#[cfg(test)]
#[path = "../tests/coverage/installer_tests.rs"]
mod tests;