use super::*;
use base64::Engine;
use std::path::Path;
use std::time::Duration;
use reqwest::header::HeaderMap;
use serde_json::Value;
use crate::agents::CodingAgent;
struct BootstrapConfigHome {
_guard: std::sync::MutexGuard<'static, ()>,
previous: Option<std::ffi::OsString>,
}
impl BootstrapConfigHome {
fn enter(path: &std::path::Path) -> Self {
let guard = crate::test_support::ENV_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let previous = std::env::var_os("XDG_CONFIG_HOME");
unsafe { std::env::set_var("XDG_CONFIG_HOME", path) };
Self {
_guard: guard,
previous,
}
}
}
impl Drop for BootstrapConfigHome {
fn drop(&mut self) {
unsafe {
match self.previous.take() {
Some(previous) => std::env::set_var("XDG_CONFIG_HOME", previous),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}
}
#[tokio::test]
async fn transparent_hook_delivery_authenticates_the_wrapper_gateway() {
let _plugin_guard = crate::test_support::PLUGIN_CONFIG_TEST_LOCK.lock().await;
let temp = tempfile::tempdir().unwrap();
let _bootstrap_home = BootstrapConfigHome::enter(&temp.path().join("xdg"));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let bind = listener.local_addr().unwrap();
let gateway_url = format!("http://{bind}");
let fingerprint = crate::configuration::transparent_gateway_fingerprint(&gateway_url);
let config = crate::configuration::GatewayConfig {
bind,
..crate::configuration::GatewayConfig::default()
};
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(crate::server::serve_transparent_listener_with_dynamic(
listener,
config,
Vec::new(),
fingerprint.clone(),
crate::provider_auth::TransparentProxyCredential::generate().unwrap(),
Some(shutdown_rx),
));
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let url = gateway_url.clone();
let fingerprint = fingerprint.clone();
if tokio::task::spawn_blocking(move || {
crate::gateway::client::healthz_compatible(&url, &fingerprint)
})
.await
.unwrap()
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("wrapper gateway did not become healthy");
let command = HookForwardRequest {
agent: CodingAgent::Codex,
gateway_url: Some(gateway_url.clone()),
generation_file: None,
generation_token: None,
forward_only: false,
transparent_run: true,
profile: None,
session_metadata: None,
gateway_mode: None,
fail_closed: true,
};
let gateway = transparent_gateway_spec(&gateway_url).unwrap();
let response = send_verified_hook_forward_request(
&command,
&gateway,
&gateway_url,
json!({
"session_id": "verified-transparent-hook",
"hook_event_name": "SessionStart"
})
.to_string(),
)
.await
.unwrap()
.unwrap();
assert_eq!(response.status, 200);
let _ = shutdown_tx.send(());
tokio::time::timeout(Duration::from_secs(5), server)
.await
.expect("wrapper gateway did not stop")
.unwrap()
.unwrap();
}
#[test]
fn hook_payload_reader_normalizes_blank_input_and_accepts_the_exact_limit() {
assert_eq!(read_hook_payload_from(" \n\t".as_bytes(), 3).unwrap(), "{}");
assert_eq!(
read_hook_payload_from("1234".as_bytes(), 4).unwrap(),
"1234"
);
}
#[test]
fn hook_payload_reader_rejects_oversized_invalid_and_unreadable_input() {
let oversized = read_hook_payload_from("12345".as_bytes(), 4)
.unwrap_err()
.to_string();
assert!(oversized.contains("exceeds the 4-byte limit"));
let invalid = read_hook_payload_from([0xff].as_slice(), 1)
.unwrap_err()
.to_string();
assert!(invalid.contains("not valid UTF-8"));
struct FailingReader;
impl std::io::Read for FailingReader {
fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("synthetic hook input failure"))
}
}
assert!(
read_hook_payload_from(FailingReader, 4)
.unwrap_err()
.to_string()
.contains("synthetic hook input failure")
);
}
#[test]
fn explicit_persistent_destinations_ignore_ambient_urls() {
let destination = resolve_hook_destination(
Some("http://installed".into()),
Some("http://dynamic".into()),
false,
false,
);
assert_eq!(destination.gateway_url, "http://installed");
assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing);
let destination = resolve_hook_destination(None, Some("http://dynamic".into()), false, false);
assert_eq!(destination.gateway_url, "http://dynamic");
assert_eq!(destination.lifecycle, HookGatewayLifecycle::Transparent);
let destination = resolve_hook_destination(
Some("http://source-plugin".into()),
Some("http://dynamic".into()),
true,
false,
);
assert_eq!(destination.gateway_url, "http://source-plugin");
assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing);
let destination = resolve_hook_destination(None, Some("http://dynamic".into()), true, false);
assert_eq!(destination.gateway_url, crate::bootstrap::DEFAULT_URL);
assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing);
let destination = resolve_hook_destination(Some("http://embedded".into()), None, false, true);
assert_eq!(destination.gateway_url, "http://embedded");
assert_eq!(destination.lifecycle, HookGatewayLifecycle::Transparent);
let destination = resolve_hook_destination(None, None, false, false);
assert_eq!(destination.gateway_url, crate::bootstrap::DEFAULT_URL);
assert_eq!(destination.lifecycle, HookGatewayLifecycle::Existing);
}
#[test]
fn verified_hook_response_rejects_invalid_status_and_fail_open_http_errors() {
let error = handle_verified_hook_forward_response(
Ok(crate::gateway::client::VerifiedHttpResponse {
status: 0,
body: Vec::new(),
}),
true,
)
.unwrap_err()
.to_string();
assert!(error.contains("invalid status"), "{error}");
handle_verified_hook_forward_response(
Ok(crate::gateway::client::VerifiedHttpResponse {
status: 0,
body: Vec::new(),
}),
false,
)
.unwrap();
handle_hook_forward_status(reqwest::StatusCode::BAD_GATEWAY, String::new(), false).unwrap();
}
#[test]
fn hook_response_statuses_preserve_guardrail_rejections_and_fail_closed_errors() {
let rejection = handle_hook_forward_status(
reqwest::StatusCode::FORBIDDEN,
r#"{"error":{"type":"nemo_relay_guardrail_rejected","reason":"policy denied"}}"#.into(),
false,
)
.unwrap_err()
.to_string();
assert!(rejection.contains("policy denied"), "{rejection}");
let fallback = handle_hook_forward_status(
reqwest::StatusCode::BAD_REQUEST,
r#"{"error":{"type":"nemo_relay_guardrail_rejected","message":"fallback"}}"#.into(),
false,
)
.unwrap_err()
.to_string();
assert!(fallback.contains("fallback"), "{fallback}");
let error = handle_hook_forward_status(
reqwest::StatusCode::BAD_GATEWAY,
"not a guardrail response".into(),
true,
)
.unwrap_err()
.to_string();
assert!(error.contains("HTTP 502"), "{error}");
}
#[test]
fn windows_hook_decoder_rejects_unsafe_odd_and_trailing_argument_envelopes() {
const SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand ";
#[cfg(windows)]
let launcher = windows_powershell_path().unwrap();
#[cfg(not(windows))]
let launcher = "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe".to_string();
assert!(decode_windows_hook_command(&format!("powershell.exe{SEPARATOR}QQ==")).is_none());
assert!(decode_windows_hook_command(&format!("{launcher}{SEPARATOR}QQ==")).is_none());
let script = "$ErrorActionPreference='Stop'; & 'relay' ; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE";
let encoded = base64::engine::general_purpose::STANDARD.encode(
script
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>(),
);
assert!(decode_windows_hook_command(&format!("{launcher}{SEPARATOR}{encoded}")).is_none());
}
#[test]
fn merge_hooks_is_idempotent_and_preserves_existing_entries() {
let existing = json!({
"hooks": {
"Stop": [{ "hooks": [{ "type": "command", "command": "existing" }] }]
}
});
let generated = generated_hooks(CodingAgent::ClaudeCode, "nemo-relay hook-forward claude");
let once = merge_hooks(existing, generated.clone()).unwrap();
let twice = merge_hooks(once.clone(), generated).unwrap();
assert_eq!(once, twice);
assert_eq!(twice["hooks"]["Stop"].as_array().unwrap().len(), 2);
assert_eq!(
twice["hooks"]["UserPromptExpansion"]
.as_array()
.unwrap()
.len(),
1
);
}
#[test]
fn merge_hooks_rejects_malformed_shapes() {
let generated = generated_hooks(CodingAgent::Codex, "cmd");
assert!(merge_hooks(json!([]), generated.clone()).is_err());
assert!(merge_hooks(json!({ "hooks": [] }), generated.clone()).is_err());
assert!(merge_hooks(json!({ "hooks": { "Stop": {} } }), generated).is_err());
assert!(merge_hooks(json!({}), json!({ "hooks": [] })).is_err());
}
#[test]
fn helper_formatting_and_headers_cover_optional_paths() {
assert!(event_matches_tools("PermissionRequest"));
assert!(!event_matches_tools("SessionStart"));
let headers = gateway_headers(
Some("profile"),
Some(r#"{"team":"obs"}"#),
Some(GatewayMode::Passthrough),
)
.unwrap();
assert_eq!(
headers
.get("x-nemo-relay-gateway-mode")
.and_then(|value| value.to_str().ok()),
Some("passthrough")
);
assert!(
insert_header(
&mut HeaderMap::new(),
"x-nemo-relay-config-profile",
Some("bad\nvalue")
)
.is_err()
);
let headers = gateway_headers(None, None, None).unwrap();
assert!(headers.is_empty());
}
#[test]
fn generated_hook_dispatch_covers_all_agents() {
for agent in [
CodingAgent::ClaudeCode,
CodingAgent::Codex,
CodingAgent::Hermes,
] {
assert!(generated_hooks(agent, "cmd")["hooks"].is_object());
}
assert_eq!(
transparent_hook_forward_command_for_platform(
Path::new("nemo-relay"),
CodingAgent::Hermes,
"http://127.0.0.1:1234",
false,
),
"nemo-relay hook-forward hermes --gateway-url http://127.0.0.1:1234 --transparent-run"
);
assert_eq!(
transparent_hook_forward_command_for_platform(
Path::new("/abs/path/to/nemo-relay"),
CodingAgent::Codex,
"http://127.0.0.1:1234",
false,
),
"/abs/path/to/nemo-relay hook-forward codex --gateway-url http://127.0.0.1:1234 --transparent-run"
);
let relay = Path::new("/opt/NeMo Relay's & tools/nemo-relay");
assert_eq!(
transparent_hook_forward_command_for_platform(
relay,
CodingAgent::Codex,
"http://127.0.0.1:1234",
false
),
r#"'/opt/NeMo Relay'\''s & tools/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:1234 --transparent-run"#
);
let native = transparent_hook_forward_command(
Path::new("nemo-relay"),
CodingAgent::Hermes,
"http://127.0.0.1:1234",
)
.unwrap();
if cfg!(windows) {
assert_eq!(
decode_windows_hook_command(&native).unwrap(),
vec![
String::from("nemo-relay"),
String::from("hook-forward"),
String::from("hermes"),
String::from("--gateway-url"),
String::from("http://127.0.0.1:1234"),
String::from("--transparent-run"),
]
);
} else {
assert_eq!(
native,
transparent_hook_forward_command_for_platform(
Path::new("nemo-relay"),
CodingAgent::Hermes,
"http://127.0.0.1:1234",
false,
)
);
}
let windows = transparent_hook_forward_command_for_platform(
relay,
CodingAgent::ClaudeCode,
"http://127.0.0.1:1234",
true,
);
let (launcher, encoded) = windows.rsplit_once(' ').unwrap();
assert_eq!(
launcher,
"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand"
);
assert!(
!encoded.is_empty()
&& encoded
.chars()
.all(|character| character.is_ascii_alphanumeric()
|| matches!(character, '+' | '/' | '='))
);
assert_eq!(
decode_windows_hook_command(&windows).unwrap(),
vec![
relay.display().to_string(),
"hook-forward".into(),
"claude".into(),
"--gateway-url".into(),
"http://127.0.0.1:1234".into(),
"--transparent-run".into(),
]
);
assert!(decode_windows_hook_command("powershell.exe -EncodedCommand invalid").is_none());
assert!(
decode_windows_hook_command(
"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand invalid payload"
)
.is_none()
);
let oversized = format!(
"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {}",
"A".repeat(8_000)
);
assert!(decode_windows_hook_command(&oversized).is_none());
let oversized_path = format!("C:/{}nemo-relay.exe", "long/".repeat(2_000));
let error = encoded_windows_hook_command(
"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
Path::new(&oversized_path),
&["hook-forward".into(), "codex".into()],
)
.unwrap_err();
assert!(error.contains("exceeds the 8000-character safety limit"));
assert!(error.contains("shorten the Relay or plugin installation path"));
}
#[test]
fn codex_generation_uses_exactly_the_supported_hook_schema() {
let generated = generated_hooks(CodingAgent::Codex, "cmd");
let events = generated["hooks"]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
events,
std::collections::BTreeSet::from([
"PermissionRequest",
"PostCompact",
"PostToolUse",
"PreCompact",
"PreToolUse",
"SessionStart",
"Stop",
"SubagentStart",
"SubagentStop",
"UserPromptSubmit",
])
);
for unsupported in ["PostToolUseFailure", "Notification", "SessionEnd"] {
assert!(generated["hooks"].get(unsupported).is_none());
}
}
#[test]
fn packaged_hook_configs_are_valid_json() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../integrations/coding-agents");
for path in [
root.join("../../.agents/plugins/marketplace.json"),
root.join("../../.claude-plugin/marketplace.json"),
root.join("claude-code/hooks/hooks.json"),
root.join("codex/hooks/hooks.json"),
root.join("claude-code/.mcp.json"),
root.join("codex/.mcp.json"),
root.join("claude-code/.claude-plugin/plugin.json"),
root.join("codex/.codex-plugin/plugin.json"),
] {
let raw = std::fs::read_to_string(&path).unwrap();
serde_json::from_str::<Value>(&raw)
.unwrap_or_else(|error| panic!("{} is invalid JSON: {error}", path.display()));
}
}
#[test]
fn packaged_plugin_hooks_use_expected_forwarding_commands() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../integrations/coding-agents");
let claude = serde_json::from_str::<Value>(
&std::fs::read_to_string(root.join("claude-code/hooks/hooks.json")).unwrap(),
)
.unwrap();
let codex = serde_json::from_str::<Value>(
&std::fs::read_to_string(root.join("codex/hooks/hooks.json")).unwrap(),
)
.unwrap();
assert_eq!(
codex["description"],
json!("SPDX-License-Identifier: Apache-2.0")
);
assert_eq!(
codex.as_object().unwrap().keys().collect::<Vec<_>>(),
vec!["description", "hooks"]
);
assert_eq!(
claude["hooks"]["SessionStart"][0]["hooks"][0]["command"],
json!(format!(
"nemo-relay hook-forward claude --gateway-url {} --forward-only",
crate::bootstrap::DEFAULT_URL
))
);
assert_eq!(
codex["hooks"]["SessionStart"][0]["hooks"][0]["command"],
json!(format!(
"nemo-relay hook-forward codex --gateway-url {} --forward-only",
crate::bootstrap::DEFAULT_URL
))
);
assert_eq!(
claude["hooks"],
generated_hooks(
CodingAgent::ClaudeCode,
&format!(
"nemo-relay hook-forward claude --gateway-url {} --forward-only",
crate::bootstrap::DEFAULT_URL
),
)["hooks"]
);
assert_eq!(
codex["hooks"],
generated_hooks(
CodingAgent::Codex,
&format!(
"nemo-relay hook-forward codex --gateway-url {} --forward-only",
crate::bootstrap::DEFAULT_URL
),
)["hooks"]
);
assert!(
claude["hooks"]
.as_object()
.unwrap()
.values()
.flat_map(|groups| groups.as_array().unwrap())
.flat_map(|group| group["hooks"].as_array().unwrap())
.all(|hook| hook["command"]
.as_str()
.is_some_and(|command| command.starts_with("nemo-relay ")))
);
assert!(
codex["hooks"]
.as_object()
.unwrap()
.values()
.flat_map(|groups| groups.as_array().unwrap())
.flat_map(|group| group["hooks"].as_array().unwrap())
.all(|hook| hook["command"]
.as_str()
.is_some_and(|command| command.starts_with("nemo-relay ")))
);
}
#[test]
fn packaged_plugin_manifests_use_stable_plugin_name_and_version() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../integrations/coding-agents");
let claude_path = root.join("claude-code/.claude-plugin/plugin.json");
let claude =
serde_json::from_str::<Value>(&std::fs::read_to_string(&claude_path).unwrap()).unwrap();
assert_eq!(claude["name"], json!("nemo-relay-plugin"));
assert_eq!(claude["version"], json!(env!("CARGO_PKG_VERSION")));
assert!(claude.get("hooks").is_none());
assert_eq!(claude["mcpServers"], json!("./.mcp.json"));
let codex_path = root.join("codex/.codex-plugin/plugin.json");
let codex =
serde_json::from_str::<Value>(&std::fs::read_to_string(&codex_path).unwrap()).unwrap();
assert_eq!(codex["name"], json!("nemo-relay-plugin"));
assert_eq!(codex["version"], json!(env!("CARGO_PKG_VERSION")));
assert!(codex.get("hooks").is_none());
assert_eq!(codex["mcpServers"], json!("./.mcp.json"));
let codex_mcp_path = root.join("codex/.mcp.json");
let codex_mcp =
serde_json::from_str::<Value>(&std::fs::read_to_string(&codex_mcp_path).unwrap()).unwrap();
let server = &codex_mcp["nemo-relay"];
assert_eq!(server["command"], json!("nemo-relay"));
assert_eq!(server["args"], json!(["mcp"]));
assert_eq!(
server["env"],
json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"})
);
assert_eq!(server["required"], json!(true));
assert_eq!(server["startup_timeout_sec"], json!(20));
assert_eq!(
server["env_vars"],
json!(crate::mcp_environment::forwarded_names_for_platform(
Vec::new(),
None,
false,
))
);
let claude_mcp_path = root.join("claude-code/.mcp.json");
let claude_mcp =
serde_json::from_str::<Value>(&std::fs::read_to_string(&claude_mcp_path).unwrap()).unwrap();
let claude_server = &claude_mcp["mcpServers"]["nemo-relay"];
assert_eq!(claude_server["command"], json!("nemo-relay"));
assert_eq!(claude_server["args"], json!(["mcp"]));
assert_eq!(
claude_server["env"],
json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"})
);
assert_eq!(claude_server["alwaysLoad"], json!(true));
let codex_marketplace_path = root.join("../../.agents/plugins/marketplace.json");
let codex_marketplace =
serde_json::from_str::<Value>(&std::fs::read_to_string(&codex_marketplace_path).unwrap())
.unwrap();
assert_eq!(codex_marketplace["name"], json!("nemo-relay"));
assert_eq!(
codex_marketplace["plugins"][0]["name"],
json!("nemo-relay-plugin")
);
assert_eq!(
codex_marketplace["plugins"][0]["source"]["path"],
json!("./integrations/coding-agents/codex")
);
let claude_marketplace_path = root.join("../../.claude-plugin/marketplace.json");
let claude_marketplace =
serde_json::from_str::<Value>(&std::fs::read_to_string(&claude_marketplace_path).unwrap())
.unwrap();
assert_eq!(claude_marketplace["name"], json!("nemo-relay"));
assert_eq!(
claude_marketplace["plugins"][0]["name"],
json!("nemo-relay-plugin")
);
assert_eq!(
claude_marketplace["plugins"][0]["source"],
json!("./integrations/coding-agents/claude-code")
);
}
#[test]
fn packaged_plugin_helpers_are_present() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../integrations/coding-agents");
for path in [
root.join("claude-code/hooks/hooks.json"),
root.join("codex/hooks/hooks.json"),
root.join("claude-code/.mcp.json"),
root.join("codex/.mcp.json"),
] {
let metadata = std::fs::metadata(&path)
.unwrap_or_else(|error| panic!("{} missing: {error}", path.display()));
assert!(metadata.is_file(), "{} is not a file", path.display());
}
}