use std::cell::Cell;
use std::ffi::OsString;
use std::path::Path;
use std::sync::MutexGuard;
use std::time::{Duration, UNIX_EPOCH};
use serde_json::{Value, json};
use super::*;
use crate::agents::CodingAgent;
const TEST_GENERATION_TOKEN: &str = "test-generation";
fn relay_binary(root: &Path) -> PathBuf {
let path = root.join("NeMo Relay's bin").join("nemo-relay");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"relay").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
path
}
fn paths(root: &Path) -> PersistentPaths {
PersistentPaths::for_config(root.join("config.yaml")).unwrap()
}
fn yaml(path: &Path) -> Value {
serde_yaml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
fn json_file(path: &Path) -> Value {
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
struct XdgConfigHomeScope {
_guard: MutexGuard<'static, ()>,
previous: Option<OsString>,
}
impl XdgConfigHomeScope {
fn enter(path: &Path) -> Self {
let guard = crate::test_support::ENV_TEST_LOCK
.lock()
.unwrap_or_else(|error| error.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 XdgConfigHomeScope {
fn drop(&mut self) {
unsafe {
match self.previous.take() {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}
}
#[test]
fn user_config_path_uses_hermes_home_or_platform_home() {
let default_home = Path::new("/users/relay");
assert_eq!(
user_config_path_with_override(default_home, None),
default_home.join(".hermes/config.yaml")
);
assert_eq!(
user_config_path_with_override(default_home, Some("/profiles/hermes".into())),
Path::new("/profiles/hermes/config.yaml")
);
assert_eq!(
user_config_path_with_override(default_home, Some("".into())),
default_home.join(".hermes/config.yaml")
);
}
#[test]
fn install_lock_serializes_concurrent_hermes_config_updates() {
let temp = tempfile::tempdir().unwrap();
let config = temp.path().join("config.yaml");
let _first = acquire_install_lock(&config, Duration::from_millis(10)).unwrap();
let error = acquire_install_lock(&config, Duration::ZERO).unwrap_err();
assert!(
error.contains("another Hermes integration update"),
"{error}"
);
}
#[test]
fn install_uses_the_native_hermes_allowlist_lock() {
let temp = tempfile::tempdir().unwrap();
let allowlist = temp.path().join("shell-hooks-allowlist.json");
let _first = acquire_allowlist_lock(&allowlist, Duration::from_millis(10)).unwrap();
let error = acquire_allowlist_lock(&allowlist, Duration::ZERO).unwrap_err();
assert!(error.contains("shell-hook approval update"), "{error}");
assert!(temp.path().join("shell-hooks-allowlist.json.lock").exists());
}
#[test]
fn hook_command_round_trips_paths_and_platform_metacharacters() {
let relay = Path::new("/tmp/NeMo $Relay`test'/bin/nemo-relay");
let generation = Path::new("/tmp/generation");
assert_eq!(
persistent_hook_commands_for_platform(relay, generation, TEST_GENERATION_TOKEN, false)
.for_event("on_session_start"),
"'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes --gateway-url http://127.0.0.1:47632 --generation-file /tmp/generation --generation-token test-generation --fail-open"
);
assert_eq!(
crate::hooks::decode_windows_hook_command(
persistent_hook_commands_for_platform(
Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"),
Path::new(r"C:\Temp\generation"),
TEST_GENERATION_TOKEN,
true,
)
.for_event("pre_tool_call")
)
.unwrap(),
vec![
r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe",
"hook-forward",
"hermes",
"--gateway-url",
crate::bootstrap::DEFAULT_URL,
"--generation-file",
r"C:\Temp\generation",
"--generation-token",
TEST_GENERATION_TOKEN,
"--fail-closed",
]
);
assert_eq!(
crate::hooks::transparent_hook_forward_commands_for_platform(
relay,
CodingAgent::Hermes,
"http://127.0.0.1:1234",
false,
)
.for_event("on_session_start"),
"'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward hermes --gateway-url http://127.0.0.1:1234 --transparent-run --fail-open"
);
let encoded = persistent_hook_commands_for_platform(
Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"),
Path::new(r"C:\Temp\generation"),
TEST_GENERATION_TOKEN,
true,
);
assert!(is_persistent_relay_hook_command(
encoded.for_event("pre_tool_call")
));
let encoded_codex = crate::hooks::persistent_hook_forward_commands_for_platform(
Path::new(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"),
CodingAgent::Codex,
Path::new(r"C:\Temp\generation"),
TEST_GENERATION_TOKEN,
true,
);
assert_ne!(encoded, encoded_codex);
assert!(!is_persistent_relay_hook_command(
encoded_codex.for_event("PreToolUse")
));
}
#[test]
fn forwarded_environment_includes_static_dynamic_and_config_referenced_names() {
let environment = vec![
"AWS_REGION".into(),
"NEMO_RELAY_CUSTOM".into(),
"NEMO_RELAY_WORKER_TOKEN".into(),
"UNRELATED_SECRET".into(),
];
let config = json!({
"header_env": {"Authorization": "CUSTOM_EXPORT_TOKEN"},
"secret_access_key_var": "AWS_PRIVATE_SECRET",
"session_token_var": "NEMO_RELAY_WORKER_TOKEN"
});
let names = forwarded_environment_names(&environment, Some(&config));
assert!(names.contains(&"ANTHROPIC_API_KEY".into()));
assert!(names.contains(&"OPENAI_API_KEY".into()));
assert!(names.contains(&"AWS_REGION".into()));
assert!(names.contains(&"NEMO_RELAY_CUSTOM".into()));
assert!(names.contains(&"CUSTOM_EXPORT_TOKEN".into()));
assert!(names.contains(&"AWS_PRIVATE_SECRET".into()));
assert!(names.contains(&"AWS_PROFILE".into()));
assert!(names.contains(&"OTEL_EXPORTER_OTLP_ENDPOINT".into()));
assert!(!names.contains(&"NEMO_RELAY_WORKER_TOKEN".into()));
assert!(!names.contains(&"UNRELATED_SECRET".into()));
}
#[test]
fn persistent_config_migrates_owned_state_and_preserves_unrelated_config() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let generation = temp.path().join(GENERATION_FILE_NAME);
let command = persistent_hook_commands(&relay, &generation, TEST_GENERATION_TOKEN).unwrap();
let legacy_command = format!("{} hook-forward hermes", relay.display());
let mut legacy_hooks = serde_json::Map::new();
for event in CodingAgent::Hermes.hook_events() {
legacy_hooks.insert(event.to_string(), json!([{"command": legacy_command}]));
}
legacy_hooks.insert(
"on_session_start".into(),
json!([
{"command": "custom-hook", "timeout": 9},
{"command": legacy_command, "timeout": 30}
]),
);
legacy_hooks.insert("custom_event".into(), json!([{"command": "keep-custom"}]));
let existing = serde_yaml::to_string(&json!({
"model": "keep-me",
"mcp_servers": {
"filesystem": {"command": "fs-mcp"},
MCP_SERVER_NAME: {"command": relay, "args": ["mcp", "--agent", "hermes"]}
},
"hooks": legacy_hooks
}))
.unwrap();
let merged = persistent_config(
Some(&existing),
&relay,
&command,
&generation,
TEST_GENERATION_TOKEN,
&["AWS_REGION".into()],
)
.unwrap();
assert_eq!(merged["model"], json!("keep-me"));
assert_eq!(
merged["mcp_servers"]["filesystem"]["command"],
json!("fs-mcp")
);
assert_eq!(
merged["mcp_servers"][MCP_SERVER_NAME],
expected_mcp_server(
&relay,
&generation,
TEST_GENERATION_TOKEN,
&["AWS_REGION".into()]
)
);
assert_eq!(
merged["mcp_servers"][MCP_SERVER_NAME]["env"]["AWS_REGION"],
json!("${AWS_REGION}")
);
assert_eq!(
merged["hooks"]["on_session_start"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(
merged["hooks"]["on_session_start"][0]["command"],
json!("custom-hook")
);
assert_eq!(
merged["hooks"]["on_session_start"][1]["command"],
json!(command.for_event("on_session_start"))
);
assert_eq!(
merged["hooks"]["custom_event"][0]["command"],
json!("keep-custom")
);
for event in CodingAgent::Hermes.hook_events() {
let groups = merged["hooks"][event].as_array().unwrap();
assert_eq!(
groups
.iter()
.filter(|group| group["command"] == json!(command.for_event(event)))
.count(),
1,
"event {event}"
);
}
}
#[test]
fn persistent_config_rejects_a_foreign_server_with_the_reserved_name() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let generation = temp.path().join(GENERATION_FILE_NAME);
let command = persistent_hook_commands(&relay, &generation, TEST_GENERATION_TOKEN).unwrap();
let existing = r#"
model: keep-me
mcp_servers:
nemo-relay:
command: foreign-mcp
args: [serve]
"#;
let error = persistent_config(
Some(existing),
&relay,
&command,
&generation,
TEST_GENERATION_TOKEN,
&[],
)
.unwrap_err()
.to_string();
assert!(error.contains("not managed by Relay"), "{error}");
assert!(error.contains("rename or remove"), "{error}");
}
#[test]
fn manual_same_named_mcp_and_hooks_are_never_claimed() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let generation = temp.path().join(GENERATION_FILE_NAME);
let command = persistent_hook_commands(&relay, &generation, TEST_GENERATION_TOKEN).unwrap();
let manual = serde_yaml::to_string(&json!({
"mcp_servers": {
MCP_SERVER_NAME: {"command": relay, "args": ["mcp"], "env": {"CUSTOM": "keep"}}
},
"hooks": {
"on_session_start": [{"command": format!("{} hook-forward hermes", relay.display())}]
}
}))
.unwrap();
let error = persistent_config(
Some(&manual),
&relay,
&command,
&generation,
TEST_GENERATION_TOKEN,
&[],
)
.unwrap_err()
.to_string();
assert!(error.contains("not managed by Relay"), "{error}");
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
std::fs::write(&paths.config, &manual).unwrap();
std::fs::write(
&paths.allowlist,
serde_json::to_vec(&json!({"approvals": [{
"event": "on_session_start",
"command": format!("{} hook-forward hermes", relay.display())
}]}))
.unwrap(),
)
.unwrap();
std::fs::write(&paths.generation, "orphaned-relay-state\n").unwrap();
uninstall_persistent_with(paths.clone(), atomic_write).unwrap();
assert_eq!(std::fs::read_to_string(&paths.config).unwrap(), manual);
assert_eq!(
json_file(&paths.allowlist)["approvals"]
.as_array()
.unwrap()
.len(),
1
);
}
#[test]
fn modern_mcp_generation_proves_ownership_independently_of_hook_completeness() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let generation = temp.path().join(GENERATION_FILE_NAME);
let mut root = persistent_config(
None,
&relay,
&persistent_hook_commands(&relay, &generation, "hook-token").unwrap(),
&generation,
"mcp-token",
&[],
)
.unwrap();
assert_eq!(
owned_install_command(&root, &relay, Some(&generation)).unwrap(),
Some(persistent_hook_commands(&relay, &generation, "mcp-token").unwrap())
);
root["mcp_servers"][MCP_SERVER_NAME]["command"] = json!(temp.path().join("other/nemo-relay"));
assert!(
owned_install_command(&root, &relay, Some(&generation))
.unwrap()
.is_none()
);
}
#[test]
fn persistent_config_migrates_modern_single_command_hooks_to_explicit_policies() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let generation = temp.path().join(GENERATION_FILE_NAME);
let commands = persistent_hook_commands(&relay, &generation, TEST_GENERATION_TOKEN).unwrap();
let mut installed = persistent_config(
None,
&relay,
&commands,
&generation,
TEST_GENERATION_TOKEN,
&[],
)
.unwrap();
let legacy = commands.legacy().unwrap();
for event in CodingAgent::Hermes.hook_events() {
installed["hooks"][event][0]["command"] = json!(legacy);
}
let migrated = persistent_config(
Some(&serde_yaml::to_string(&installed).unwrap()),
&relay,
&commands,
&generation,
TEST_GENERATION_TOKEN,
&[],
)
.unwrap();
for event in CodingAgent::Hermes.hook_events() {
let hooks = migrated["hooks"][event].as_array().unwrap();
assert_eq!(hooks.len(), 1, "event {event}");
assert_eq!(hooks[0]["command"], json!(commands.for_event(event)));
}
}
#[test]
fn foreign_reserved_server_aborts_install_before_any_file_changes() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
let config =
b"# preserve\nmcp_servers:\n nemo-relay:\n command: foreign-mcp\n args: [serve]\n";
let allowlist = b"{\"approvals\":[{\"event\":\"custom\",\"command\":\"custom-hook\"}]}\n";
std::fs::write(&paths.config, config).unwrap();
std::fs::write(&paths.allowlist, allowlist).unwrap();
let error = install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write)
.unwrap_err()
.to_string();
assert!(error.contains("not managed by Relay"), "{error}");
assert_eq!(std::fs::read(&paths.config).unwrap(), config);
assert_eq!(std::fs::read(&paths.allowlist).unwrap(), allowlist);
assert!(!paths.generation.exists());
}
#[test]
fn trusted_hooks_migrates_only_relay_approvals_and_records_every_event() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let generation = temp.path().join(GENERATION_FILE_NAME);
let command = persistent_hook_commands(&relay, &generation, TEST_GENERATION_TOKEN).unwrap();
let existing = json!({
"schema": 7,
"approvals": [
{"event": "custom", "command": "custom-hook", "approved_at": "keep"},
{"event": "on_session_start", "command": "nemo-relay hook-forward hermes"},
{"event": "on_session_end", "command": "/old/nemo-relay plugin-shim hook hermes"}
]
});
let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let legacy = crate::hooks::GeneratedHookCommands::uniform("nemo-relay hook-forward hermes");
let merged = trusted_hooks(
Some(&serde_json::to_string(&existing).unwrap()),
Some(&legacy),
&command,
&relay,
now,
)
.unwrap();
let approvals = merged["approvals"].as_array().unwrap();
assert_eq!(merged["schema"], json!(7));
assert!(
approvals
.iter()
.any(|entry| entry["command"] == json!("custom-hook"))
);
assert_eq!(approvals.len(), CodingAgent::Hermes.hook_events().len() + 2);
for event in CodingAgent::Hermes.hook_events() {
let entries = approvals
.iter()
.filter(|entry| {
entry["event"] == json!(event)
&& entry["command"] == json!(command.for_event(event))
})
.collect::<Vec<_>>();
assert_eq!(entries.len(), 1, "event {event}");
assert_eq!(
entries[0]["approved_at"],
json!("2023-11-14T22:13:20.000000Z")
);
assert!(entries[0].get("script_mtime_at_approval").is_some());
}
}
#[test]
fn verification_rejects_relay_handlers_and_approvals_on_unexpected_events() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let generation = temp.path().join(GENERATION_FILE_NAME);
let command = persistent_hook_commands(&relay, &generation, TEST_GENERATION_TOKEN).unwrap();
let mut config = persistent_config(
None,
&relay,
&command,
&generation,
TEST_GENERATION_TOKEN,
&[],
)
.unwrap();
config["hooks"]["unexpected_event"] =
json!([{"command": command.for_event("on_session_start"), "timeout": 30}]);
let error = verify_hook_definitions(&config, &command).unwrap_err();
assert!(error.contains("unexpected Relay hook"));
let mut malformed = persistent_config(
None,
&relay,
&command,
&generation,
TEST_GENERATION_TOKEN,
&[],
)
.unwrap();
malformed["hooks"]["unexpected_event"] =
json!({"command": command.for_event("on_session_start")});
let error = verify_hook_definitions(&malformed, &command).unwrap_err();
assert!(error.contains("must be an array"));
let mut allowlist = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap();
allowlist["approvals"].as_array_mut().unwrap().push(json!({
"event": "unexpected_event",
"command": command.for_event("on_session_start"),
"approved_at": "1970-01-01T00:00:00.000000Z"
}));
let path = temp.path().join("shell-hooks-allowlist.json");
std::fs::write(&path, serde_json::to_vec(&allowlist).unwrap()).unwrap();
let error = verify_trust(&path, &command).unwrap_err();
assert!(error.contains("unexpected Relay hook approval"));
let mut missing_event = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap();
missing_event["approvals"]
.as_array_mut()
.unwrap()
.push(json!({
"command": command.for_event("on_session_start"),
"approved_at": "1970-01-01T00:00:00.000000Z"
}));
std::fs::write(&path, serde_json::to_vec(&missing_event).unwrap()).unwrap();
let error = verify_trust(&path, &command).unwrap_err();
assert!(error.contains("missing its event"));
let mut wrong_policy = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap();
wrong_policy["approvals"]
.as_array_mut()
.unwrap()
.push(json!({
"event": "pre_tool_call",
"command": command.for_event("on_session_start"),
"approved_at": "1970-01-01T00:00:00.000000Z"
}));
std::fs::write(&path, serde_json::to_vec(&wrong_policy).unwrap()).unwrap();
let error = verify_trust(&path, &command).unwrap_err();
assert!(error.contains("unexpected Relay hook approval"));
}
#[test]
fn hermes_structure_and_trust_validation_cover_exact_failure_shapes() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let generation = temp.path().join(GENERATION_FILE_NAME);
let command = persistent_hook_commands(&relay, &generation, TEST_GENERATION_TOKEN).unwrap();
let error = trusted_hooks(
Some(r#"{"approvals": {}}"#),
None,
&command,
&relay,
UNIX_EPOCH,
)
.unwrap_err()
.to_string();
assert!(error.contains("approvals must be an array"), "{error}");
let error = parse_json_object(Some("[]"), "test allowlist")
.unwrap_err()
.to_string();
assert!(error.contains("must contain a JSON object"), "{error}");
let mut malformed_hooks = json!({"hooks": {"on_session_start": {}}});
let error = strip_owned_hooks(&mut malformed_hooks, Some(&command))
.unwrap_err()
.to_string();
assert!(
error.contains("on_session_start hooks must be an array"),
"{error}"
);
let error = parse_yaml_object(Some("[]"), "test config")
.unwrap_err()
.to_string();
assert!(error.contains("must contain an object"), "{error}");
let path = temp.path().join("shell-hooks-allowlist.json");
let mut missing = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap();
missing["approvals"].as_array_mut().unwrap().remove(0);
std::fs::write(&path, serde_json::to_vec(&missing).unwrap()).unwrap();
let error = verify_trust(&path, &command).unwrap_err();
assert!(
error.contains("expected exactly one trust approval"),
"{error}"
);
let mut with_opaque_entry = trusted_hooks(None, None, &command, &relay, UNIX_EPOCH).unwrap();
with_opaque_entry["approvals"]
.as_array_mut()
.unwrap()
.push(json!({"metadata": "unrelated"}));
std::fs::write(&path, serde_json::to_vec(&with_opaque_entry).unwrap()).unwrap();
verify_trust(&path, &command).unwrap();
}
#[test]
fn install_is_verified_idempotent_and_rotates_the_generation() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
let environment = vec!["OTEL_SERVICE_NAME".into()];
let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let written =
install_persistent_with(paths.clone(), &relay, &environment, None, now, atomic_write)
.unwrap();
assert_eq!(written, paths.all());
let first_generation =
crate::installation::generation::InstallGeneration::capture(paths.generation.clone())
.unwrap()
.token()
.to_owned();
let first_config = yaml(&paths.config);
let first_command = first_config["hooks"]["on_session_start"][0]["command"]
.as_str()
.unwrap()
.to_string();
assert_eq!(
first_config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV],
json!(first_generation)
);
assert!(crate::hook_assertions::command_has_arguments(
&first_command,
&["--generation-token", &first_generation]
));
install_persistent_with(paths.clone(), &relay, &environment, None, now, atomic_write).unwrap();
let second_generation =
crate::installation::generation::InstallGeneration::capture(paths.generation.clone())
.unwrap()
.token()
.to_owned();
assert_ne!(first_generation, second_generation);
let config = yaml(&paths.config);
let second_command =
persistent_hook_commands(&relay, &paths.generation, &second_generation).unwrap();
assert_eq!(
config["hooks"]["on_session_start"]
.as_array()
.unwrap()
.iter()
.filter(|group| {
group["command"] == json!(second_command.for_event("on_session_start"))
})
.count(),
1
);
assert_eq!(
config["hooks"]["on_session_start"]
.as_array()
.unwrap()
.iter()
.filter(|group| group["command"] == json!(first_command))
.count(),
0
);
assert_eq!(
config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_FILE_ENV],
json!(paths.generation.display().to_string())
);
assert_eq!(
config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV],
json!(second_generation)
);
assert_ne!(
first_config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV],
config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV]
);
assert!(crate::hook_assertions::command_has_arguments(
&first_command,
&["--generation-token", &first_generation]
));
assert!(!crate::hook_assertions::command_has_arguments(
&first_command,
&["--generation-token", &second_generation]
));
assert_eq!(
config["mcp_servers"][MCP_SERVER_NAME]["env"]["OTEL_SERVICE_NAME"],
json!("${OTEL_SERVICE_NAME}")
);
assert_eq!(
json_file(&paths.allowlist)["approvals"]
.as_array()
.unwrap()
.len(),
CodingAgent::Hermes.hook_events().len()
);
}
#[test]
fn reinstall_verifies_generation_through_the_existing_retirement_transaction() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap();
let first_token = InstallGeneration::capture(paths.generation.clone())
.unwrap()
.token()
.to_owned();
let mut retirement = GenerationRetirement::acquire(&paths.generation)
.unwrap()
.unwrap();
retirement.invalidate_for_replacement().unwrap();
let result = install_persistent_with_generation(
paths.clone(),
&relay,
&[],
None,
Some(&retirement),
UNIX_EPOCH,
atomic_write,
);
finish_generation_mutation(result, Some(&mut retirement), "install").unwrap();
drop(retirement);
let second_token = InstallGeneration::capture(paths.generation)
.unwrap()
.token()
.to_owned();
assert_ne!(first_token, second_token);
}
#[test]
fn diagnosis_rejects_a_stale_mcp_generation_identity() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap();
let mut config = yaml(&paths.config);
config["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_TOKEN_ENV] = json!("stale-generation");
std::fs::write(&paths.config, serde_yaml::to_string(&config).unwrap()).unwrap();
let error = diagnose_persistent(&paths.config).unwrap_err();
assert!(
error.contains("expected generation identity is stale"),
"{error}"
);
}
#[test]
fn install_rolls_back_config_allowlist_and_generation_after_write_failure() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
let originals = [
(&paths.config, b"model: original\n".as_slice()),
(
&paths.allowlist,
b"{\"approvals\":[{\"event\":\"x\",\"command\":\"custom\"}]}\n".as_slice(),
),
(&paths.generation, b"original-generation\n".as_slice()),
];
for (path, bytes) in originals {
std::fs::write(path, bytes).unwrap();
}
let before = paths.all().map(|path| std::fs::read(path).unwrap());
let writes = Cell::new(0);
let error = install_persistent_with(
paths.clone(),
&relay,
&[],
None,
UNIX_EPOCH,
|path, bytes| {
let write = writes.get() + 1;
writes.set(write);
if write == 3 {
return Err("injected config write failure".into());
}
atomic_write(path, bytes)
},
)
.unwrap_err()
.to_string();
assert!(error.contains("injected config write failure"), "{error}");
for (index, path) in paths.all().iter().enumerate() {
assert_eq!(
std::fs::read(path).unwrap(),
before[index],
"{}",
path.display()
);
}
}
#[cfg(unix)]
#[test]
fn install_rollback_restores_original_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
let originals = [
(&paths.config, b"model: original\n".as_slice(), 0o640),
(
&paths.allowlist,
b"{\"approvals\":[{\"event\":\"x\",\"command\":\"custom\"}]}\n".as_slice(),
0o644,
),
(
&paths.generation,
b"original-generation\n".as_slice(),
0o600,
),
];
for (path, bytes, mode) in originals {
std::fs::write(path, bytes).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
}
let expected_modes = paths
.all()
.map(|path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777);
let writes = Cell::new(0);
install_persistent_with(
paths.clone(),
&relay,
&[],
None,
UNIX_EPOCH,
|path, bytes| {
let write = writes.get() + 1;
writes.set(write);
if write == 3 {
return Err("injected config write failure".into());
}
atomic_write(path, bytes)?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.map_err(|error| error.to_string())
},
)
.unwrap_err();
for (index, path) in paths.all().iter().enumerate() {
assert_eq!(
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
expected_modes[index],
"{}",
path.display()
);
}
}
#[test]
fn composed_install_rollback_restores_the_visible_preexisting_generation() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap();
let previous =
crate::installation::generation::InstallGeneration::capture(paths.generation.clone())
.unwrap();
let mut retirement = GenerationRetirement::acquire(&paths.generation)
.unwrap()
.unwrap();
retirement.invalidate_for_replacement().unwrap();
let writes = Cell::new(0);
let result = install_persistent_with(
paths.clone(),
&relay,
&[],
None,
UNIX_EPOCH,
|path, bytes| {
let write = writes.get() + 1;
writes.set(write);
if write == 3 {
return Err("injected composed install failure".into());
}
atomic_write(path, bytes)
},
);
let error = finish_generation_mutation(result, Some(&mut retirement), "install")
.unwrap_err()
.to_string();
assert!(
error.contains("injected composed install failure"),
"{error}"
);
previous.verify_current().unwrap();
crate::installation::generation::InstallGeneration::capture(paths.generation).unwrap();
}
#[test]
fn install_rolls_back_after_post_write_verification_failure() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
std::fs::write(&paths.config, "model: original\n").unwrap();
std::fs::write(&paths.allowlist, "{\"approvals\":[]}\n").unwrap();
std::fs::write(&paths.generation, "old\n").unwrap();
let before = paths.all().map(|path| std::fs::read(path).unwrap());
let corrupted = Cell::new(false);
let error = install_persistent_with(
paths.clone(),
&relay,
&[],
None,
UNIX_EPOCH,
|path, bytes| {
if path == paths.config && !corrupted.replace(true) {
return atomic_write(path, b"hooks: invalid-shape\n");
}
atomic_write(path, bytes)
},
)
.unwrap_err()
.to_string();
assert!(
error.contains("Hermes MCP server did not persist exactly"),
"{error}"
);
for (index, path) in paths.all().iter().enumerate() {
assert_eq!(
std::fs::read(path).unwrap(),
before[index],
"{}",
path.display()
);
}
}
#[test]
fn uninstall_removes_only_relay_owned_hermes_state() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
std::fs::write(
&paths.config,
"model: keep\nmcp_servers:\n filesystem:\n command: fs-mcp\nhooks:\n custom_event:\n - command: custom-hook\n",
)
.unwrap();
std::fs::write(
&paths.allowlist,
"{\"owner\":\"user\",\"approvals\":[{\"event\":\"custom_event\",\"command\":\"custom-hook\"}]}\n",
)
.unwrap();
install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap();
let removed = uninstall_persistent_with(paths.clone(), atomic_write).unwrap();
assert_eq!(removed, paths.all());
assert!(!paths.generation.exists());
let config = yaml(&paths.config);
assert_eq!(config["model"], json!("keep"));
assert_eq!(
config["mcp_servers"]["filesystem"]["command"],
json!("fs-mcp")
);
assert!(config["mcp_servers"].get(MCP_SERVER_NAME).is_none());
assert_eq!(
config["hooks"]["custom_event"][0]["command"],
json!("custom-hook")
);
let allowlist = json_file(&paths.allowlist);
assert_eq!(allowlist["owner"], json!("user"));
assert_eq!(allowlist["approvals"].as_array().unwrap().len(), 1);
assert_eq!(allowlist["approvals"][0]["command"], json!("custom-hook"));
}
#[test]
fn uninstall_removes_orphaned_generated_approval_without_config() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
let commands =
persistent_hook_commands(&relay, &paths.generation, TEST_GENERATION_TOKEN).unwrap();
std::fs::create_dir_all(paths.allowlist.parent().unwrap()).unwrap();
std::fs::write(
&paths.allowlist,
serde_json::to_vec(&json!({
"approvals": [
{
"event": "pre_tool_call",
"command": commands.for_event("pre_tool_call")
},
{
"event": "custom_event",
"command": "custom-hook"
}
]
}))
.unwrap(),
)
.unwrap();
let removed = uninstall_persistent_with(paths.clone(), atomic_write).unwrap();
assert_eq!(removed, vec![paths.allowlist.clone()]);
assert_eq!(
json_file(&paths.allowlist)["approvals"],
json!([{"event": "custom_event", "command": "custom-hook"}])
);
}
#[test]
fn uninstall_rolls_back_every_file_when_commit_fails() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
std::fs::write(&paths.config, "model: keep\n").unwrap();
std::fs::write(&paths.allowlist, "{\"owner\":\"keep\"}\n").unwrap();
install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap();
let before = paths.all().map(|path| std::fs::read(path).unwrap());
let writes = Cell::new(0);
let error = uninstall_persistent_with(paths.clone(), |path, bytes| {
let write = writes.get() + 1;
writes.set(write);
if write == 2 {
return Err("injected uninstall config failure".into());
}
atomic_write(path, bytes)
})
.unwrap_err()
.to_string();
assert!(
error.contains("injected uninstall config failure"),
"{error}"
);
for (index, path) in paths.all().iter().enumerate() {
assert_eq!(
std::fs::read(path).unwrap(),
before[index],
"{}",
path.display()
);
}
}
#[test]
fn composed_uninstall_rollback_restores_the_visible_preexisting_generation() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
std::fs::write(&paths.config, "model: keep\n").unwrap();
std::fs::write(&paths.allowlist, "{\"owner\":\"keep\"}\n").unwrap();
install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap();
let previous =
crate::installation::generation::InstallGeneration::capture(paths.generation.clone())
.unwrap();
let mut retirement = GenerationRetirement::acquire(&paths.generation)
.unwrap()
.unwrap();
retirement.invalidate_for_replacement().unwrap();
let writes = Cell::new(0);
let result = uninstall_persistent_with(paths.clone(), |path, bytes| {
let write = writes.get() + 1;
writes.set(write);
if write == 2 {
return Err("injected composed uninstall failure".into());
}
atomic_write(path, bytes)
});
let error = finish_generation_mutation(result, Some(&mut retirement), "uninstall")
.unwrap_err()
.to_string();
assert!(
error.contains("injected composed uninstall failure"),
"{error}"
);
previous.verify_current().unwrap();
crate::installation::generation::InstallGeneration::capture(paths.generation).unwrap();
}
#[test]
fn uninstall_noops_without_creating_a_hermes_home() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("missing-hermes-home");
let config = home.join("config.yaml");
assert!(uninstall_persistent(&config).unwrap().is_empty());
assert!(!home.exists());
}
#[test]
fn unrelated_hermes_files_are_not_owned_or_rewritten_by_uninstall() {
let temp = tempfile::tempdir().unwrap();
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
let config = b"# preserve this exact formatting\nmodel: custom\nmcp_servers:\n nemo-relay:\n command: foreign-mcp\n args: [serve]\n";
let allowlist = b"{ \"approvals\": [{\"event\":\"custom\",\"command\":\"custom-hook\"}] }\n";
std::fs::write(&paths.config, config).unwrap();
std::fs::write(&paths.allowlist, allowlist).unwrap();
assert!(!persistent_state_exists(&paths.config));
assert!(uninstall_persistent(&paths.config).unwrap().is_empty());
assert_eq!(std::fs::read(&paths.config).unwrap(), config);
assert_eq!(std::fs::read(&paths.allowlist).unwrap(), allowlist);
assert!(!paths.generation.exists());
}
#[test]
fn persistent_state_detection_recognizes_each_relay_owned_surface() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let roots = ["generation", "mcp", "hook", "approval"].map(|name| {
let paths = paths(&temp.path().join(name));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
paths
});
std::fs::write(&roots[0].generation, "active\n").unwrap();
std::fs::write(
&roots[1].config,
serde_yaml::to_string(&json!({
"mcp_servers": {MCP_SERVER_NAME: expected_mcp_server(
&relay,
&roots[1].generation,
TEST_GENERATION_TOKEN,
&[]
)}
}))
.unwrap(),
)
.unwrap();
std::fs::write(
&roots[2].config,
serde_yaml::to_string(&json!({
"hooks": {
"on_session_start": [{"command": persistent_hook_commands(
&relay,
&roots[2].generation,
TEST_GENERATION_TOKEN
).unwrap().for_event("on_session_start")}]
}
}))
.unwrap(),
)
.unwrap();
std::fs::write(
&roots[3].allowlist,
serde_json::to_vec(&json!({
"approvals": [{
"event": "on_session_start",
"command": persistent_hook_commands(
&relay,
&roots[3].generation,
TEST_GENERATION_TOKEN
).unwrap().for_event("on_session_start")
}]
}))
.unwrap(),
)
.unwrap();
for paths in [&roots[0], &roots[1], &roots[3]] {
assert!(
persistent_state_exists(&paths.config),
"managed state at {} was not detected",
paths.config.display()
);
}
assert!(!persistent_state_exists(&roots[2].config));
}
#[test]
fn transparent_config_suppresses_only_the_managed_mcp_and_uses_one_relay_hook() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let command = crate::hooks::transparent_hook_forward_commands(
&relay,
CodingAgent::Hermes,
"http://127.0.0.1:1234",
)
.unwrap();
let generation = temp.path().join(GENERATION_FILE_NAME);
let persistent_command =
persistent_hook_commands(&relay, &generation, TEST_GENERATION_TOKEN).unwrap();
let mut existing = persistent_config(
None,
&relay,
&persistent_command,
&generation,
TEST_GENERATION_TOKEN,
&[],
)
.unwrap();
existing["mcp_servers"]["filesystem"] = json!({"command": "fs-mcp"});
existing["hooks"]["on_session_start"]
.as_array_mut()
.unwrap()
.push(json!({"command": "custom-hook"}));
let existing = serde_yaml::to_string(&existing).unwrap();
let patched: Value = serde_yaml::from_str(
&transparent_config(&existing, &relay, "http://127.0.0.1:1234").unwrap(),
)
.unwrap();
assert!(patched["mcp_servers"].get(MCP_SERVER_NAME).is_none());
assert_eq!(
patched["mcp_servers"]["filesystem"]["command"],
json!("fs-mcp")
);
assert_eq!(patched["model"]["provider"], json!("custom"));
assert_eq!(
patched["model"]["api_key"],
json!(format!(
"${{{}}}",
crate::provider_auth::TRANSPARENT_PROXY_CREDENTIAL_ENV
))
);
for event in CodingAgent::Hermes.hook_events() {
let groups = patched["hooks"][event].as_array().unwrap();
assert_eq!(
groups
.iter()
.filter_map(|group| group.get("command").and_then(Value::as_str))
.filter(|candidate| *candidate == command.for_event(event))
.count(),
1,
"event {event}"
);
assert!(
groups
.iter()
.any(|group| group["command"] == json!(command.for_event(event)))
);
}
assert!(
patched["hooks"]["on_session_start"]
.as_array()
.unwrap()
.iter()
.any(|group| group["command"] == json!("custom-hook"))
);
}
#[test]
fn malformed_user_files_fail_before_any_state_is_replaced() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.config.parent().unwrap()).unwrap();
std::fs::write(&paths.config, "hooks: [not-an-object]\n").unwrap();
std::fs::write(&paths.allowlist, "{\"approvals\":[]}").unwrap();
std::fs::write(&paths.generation, "old\n").unwrap();
let before = paths.all().map(|path| std::fs::read(path).unwrap());
assert!(
install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write,)
.is_err()
);
for (index, path) in paths.all().iter().enumerate() {
assert_eq!(std::fs::read(path).unwrap(), before[index]);
}
}
#[test]
fn hermes_entrypoints_reject_missing_or_foreign_relay_binaries() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("hermes/config.yaml");
let missing_relay = temp.path().join("missing/nemo-relay");
let error = install_persistent(&config_path, &missing_relay)
.unwrap_err()
.to_string();
assert!(error.contains("missing or not executable"), "{error}");
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(
&config_path,
format!(
"mcp_servers:\n {MCP_SERVER_NAME}:\n command: {}\n args: [mcp]\n",
missing_relay.display()
),
)
.unwrap();
let error = configured_relay_executable(&config_path).unwrap_err();
assert!(error.contains("not a managed Relay MCP client"), "{error}");
let foreign = json!({
"mcp_servers": {
MCP_SERVER_NAME: {
"command": "foreign-mcp",
"args": ["serve"]
}
}
});
let error = relay_executable_from_config(&foreign).unwrap_err();
assert!(error.contains("not a managed Relay MCP client"), "{error}");
}
#[test]
fn hermes_diagnosis_validates_binary_bind_generation_and_environment() {
let temp = tempfile::tempdir().unwrap();
let _config_home = XdgConfigHomeScope::enter(&temp.path().join("xdg"));
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap();
let original = yaml(&paths.config);
std::fs::remove_file(&relay).unwrap();
let error = diagnose_persistent(&paths.config).unwrap_err();
assert!(error.contains("missing or not executable"), "{error}");
let relay = relay_binary(temp.path());
let mut wrong_bind = original.clone();
wrong_bind["mcp_servers"][MCP_SERVER_NAME]["env"]["NEMO_RELAY_GATEWAY_BIND"] =
json!("127.0.0.1:1");
std::fs::write(&paths.config, serde_yaml::to_string(&wrong_bind).unwrap()).unwrap();
let error = diagnose_persistent(&paths.config).unwrap_err();
assert!(error.contains("not a managed Relay MCP client"), "{error}");
let mut wrong_generation = original.clone();
wrong_generation["mcp_servers"][MCP_SERVER_NAME]["env"][GENERATION_FILE_ENV] =
json!(temp.path().join("wrong-generation").display().to_string());
std::fs::write(
&paths.config,
serde_yaml::to_string(&wrong_generation).unwrap(),
)
.unwrap();
let error = diagnose_persistent(&paths.config).unwrap_err();
assert!(
error.contains("generation fence points at the wrong file"),
"{error}"
);
let mut missing_environment = original;
assert!(
missing_environment["mcp_servers"][MCP_SERVER_NAME]["env"]
.as_object_mut()
.unwrap()
.remove("OPENAI_API_KEY")
.is_some()
);
std::fs::write(
&paths.config,
serde_yaml::to_string(&missing_environment).unwrap(),
)
.unwrap();
let error = diagnose_persistent(&paths.config).unwrap_err();
assert!(error.contains("missing environment names"), "{error}");
assert!(error.contains("OPENAI_API_KEY"), "{error}");
assert!(error.contains("install hermes --force"), "{error}");
assert!(relay.exists());
}
#[test]
fn hermes_generation_finish_preserves_primary_errors_and_reports_restore_failures() {
let primary = CliError::Install("primary failure".into());
let error = finish_generation_mutation::<()>(Err(primary), None, "install")
.unwrap_err()
.to_string();
assert!(error.contains("primary failure"), "{error}");
let temp = tempfile::tempdir().unwrap();
let generation = temp.path().join(GENERATION_FILE_NAME);
crate::installation::generation::write_new_generation(&generation).unwrap();
let mut retirement = GenerationRetirement::acquire(&generation).unwrap().unwrap();
retirement.invalidate_for_replacement().unwrap();
std::fs::write(&generation, "foreign-generation\n").unwrap();
let error = finish_generation_mutation::<()>(
Err(CliError::Install("mutation failed".into())),
Some(&mut retirement),
"install",
)
.unwrap_err()
.to_string();
assert!(error.contains("mutation failed"), "{error}");
assert!(error.contains("additionally failed to restore"), "{error}");
}
#[test]
fn hermes_uninstall_and_verification_reject_malformed_or_residual_state() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let hermes_paths = paths(&temp.path().join("hermes"));
install_persistent_with(
hermes_paths.clone(),
&relay,
&[],
None,
UNIX_EPOCH,
atomic_write,
)
.unwrap();
let config = yaml(&hermes_paths.config);
let token = InstallGeneration::capture(hermes_paths.generation.clone())
.unwrap()
.token()
.to_owned();
let command = persistent_hook_commands(&relay, &hermes_paths.generation, &token).unwrap();
let expected_environment = forwarded_environment_names(&[], None);
let mut duplicate_hook = config.clone();
duplicate_hook["hooks"]["on_session_start"]
.as_array_mut()
.unwrap()
.push(json!({"command": command.for_event("on_session_start")}));
let error = verify_hook_definitions(&duplicate_hook, &command).unwrap_err();
assert!(
error.contains("exactly one trusted Relay handler"),
"{error}"
);
let mut harmless_missing_command = config.clone();
harmless_missing_command["hooks"]
.as_object_mut()
.unwrap()
.insert("custom".into(), json!([{"timeout": 1}]));
verify_hook_definitions(&harmless_missing_command, &command).unwrap();
verify_install(
&hermes_paths,
&relay,
&command,
&expected_environment,
&token,
None,
)
.unwrap();
let mut mismatched_environment = config.clone();
let environment_name = expected_environment
.first()
.expect("persistent MCP environment is non-empty");
mismatched_environment["mcp_servers"][MCP_SERVER_NAME]["env"][environment_name] =
json!("unexpected-value");
std::fs::write(
&hermes_paths.config,
serde_yaml::to_string(&mismatched_environment).unwrap(),
)
.unwrap();
let error = diagnose_persistent(&hermes_paths.config).unwrap_err();
assert!(error.contains(environment_name), "{error}");
install_persistent_with(
hermes_paths.clone(),
&relay,
&expected_environment,
None,
UNIX_EPOCH,
atomic_write,
)
.unwrap();
let expected_token = InstallGeneration::capture(hermes_paths.generation.clone())
.unwrap()
.token()
.to_owned();
let command =
persistent_hook_commands(&relay, &hermes_paths.generation, &expected_token).unwrap();
crate::installation::generation::write_new_generation(&hermes_paths.generation).unwrap();
let error = verify_install(
&hermes_paths,
&relay,
&command,
&expected_environment,
&expected_token,
None,
)
.unwrap_err();
assert!(
error.contains("generation did not persist exactly"),
"{error}"
);
let malformed_paths = paths(&temp.path().join("malformed"));
std::fs::create_dir_all(malformed_paths.config.parent().unwrap()).unwrap();
std::fs::write(&malformed_paths.allowlist, r#"{"approvals":{}}"#).unwrap();
let error = uninstall_persistent_with(malformed_paths, atomic_write)
.unwrap_err()
.to_string();
assert!(error.contains("approvals must be an array"), "{error}");
}
#[test]
fn hermes_uninstall_verifier_identifies_each_residual_owned_surface() {
let temp = tempfile::tempdir().unwrap();
let relay = relay_binary(temp.path());
let paths = paths(&temp.path().join("hermes"));
install_persistent_with(paths.clone(), &relay, &[], None, UNIX_EPOCH, atomic_write).unwrap();
let command = owned_command_from_config(&yaml(&paths.config), Some(&paths.generation));
let error = verify_uninstall(&paths, command.as_ref()).unwrap_err();
assert!(error.contains("generation fence still exists"), "{error}");
std::fs::remove_file(&paths.generation).unwrap();
let error = verify_uninstall(&paths, command.as_ref()).unwrap_err();
assert!(
error.contains("managed Hermes Relay config still exists"),
"{error}"
);
std::fs::remove_file(&paths.config).unwrap();
let error = verify_uninstall(&paths, command.as_ref()).unwrap_err();
assert!(
error.contains("managed Hermes Relay trust approval still exists"),
"{error}"
);
}
#[test]
fn hermes_file_helpers_report_path_lock_read_remove_and_restore_failures() {
let temp = tempfile::tempdir().unwrap();
let error = PersistentPaths::for_config(PathBuf::from("/"))
.unwrap_err()
.to_string();
assert!(error.contains("has no parent directory"), "{error}");
let error = acquire_install_lock(Path::new("/"), Duration::ZERO).unwrap_err();
assert!(error.contains("has no parent directory"), "{error}");
let parent_file = temp.path().join("parent-file");
std::fs::write(&parent_file, "file").unwrap();
let error = acquire_allowlist_lock(&parent_file.join("allowlist"), Duration::ZERO).unwrap_err();
assert!(error.contains("failed to create"), "{error}");
let error =
acquire_allowlist_lock(&parent_file.join("nested/allowlist"), Duration::ZERO).unwrap_err();
assert!(error.contains("failed to create"), "{error}");
let allowlist = temp.path().join("allowlist.json");
let lock_dir = temp.path().join("allowlist.json.lock");
std::fs::create_dir(&lock_dir).unwrap();
let error = acquire_allowlist_lock(&allowlist, Duration::ZERO).unwrap_err();
assert!(
error.contains("failed to open Hermes install lock"),
"{error}"
);
let held_config = temp.path().join("held/config.yaml");
let _held = acquire_install_lock(&held_config, Duration::ZERO).unwrap();
let error = acquire_install_lock(&held_config, Duration::from_millis(30)).unwrap_err();
assert!(error.contains("timed out waiting"), "{error}");
let directory = temp.path().join("directory");
std::fs::create_dir(&directory).unwrap();
let error = read_optional_utf8(&directory).unwrap_err().to_string();
assert!(error.contains("failed to read"), "{error}");
let error = match FileSnapshot::capture(&directory) {
Ok(_) => panic!("directory snapshot unexpectedly succeeded"),
Err(error) => error.to_string(),
};
assert!(error.contains("failed to snapshot"), "{error}");
let error = remove_optional_file(&directory).unwrap_err();
assert!(error.contains("failed to remove"), "{error}");
remove_optional_file(&temp.path().join("missing")).unwrap();
let restored = temp.path().join("restored");
std::fs::write(&restored, "original").unwrap();
let snapshot = FileSnapshot::capture(&restored).unwrap();
std::fs::remove_file(&restored).unwrap();
let error = snapshot.restore(&mut |_path, _bytes| Ok(())).unwrap_err();
assert!(error.contains("failed to restore permissions"), "{error}");
let absent = temp.path().join("absent");
let snapshot = FileSnapshot::capture(&absent).unwrap();
std::fs::write(&absent, "transient").unwrap();
snapshot.restore(&mut atomic_write).unwrap();
assert!(!absent.exists());
}
#[test]
fn hermes_rollback_reports_both_primary_and_snapshot_restore_errors() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state");
std::fs::write(&path, "original").unwrap();
let snapshot = FileSnapshot::capture(&path).unwrap();
let error = rollback_error::<(), _>(
"install",
"primary failure".into(),
&[snapshot],
&mut |_path, _bytes| Err("restore failure".into()),
)
.unwrap_err()
.to_string();
assert!(error.contains("primary failure"), "{error}");
assert!(
error.contains("rollback also failed: restore failure"),
"{error}"
);
}
#[test]
fn hermes_uninstall_preserves_an_ambiguous_manual_allowlist() {
let temp = tempfile::tempdir().unwrap();
let paths = paths(&temp.path().join("hermes"));
std::fs::create_dir_all(paths.allowlist.parent().unwrap()).unwrap();
std::fs::write(
&paths.allowlist,
serde_json::to_vec(&json!({
"approvals": [{
"event": "on_session_start",
"command": "nemo-relay hook-forward hermes"
}]
}))
.unwrap(),
)
.unwrap();
let affected = uninstall_persistent_with(paths.clone(), atomic_write).unwrap();
assert_eq!(affected, vec![paths.allowlist.clone()]);
assert!(paths.allowlist.exists());
assert_eq!(
json_file(&paths.allowlist)["approvals"]
.as_array()
.unwrap()
.len(),
1
);
}