use super::*;
use crate::config::{AgentCommandConfig, GatewayConfig};
use std::ffi::OsString;
use std::sync::Mutex;
fn current_dir_lock() -> &'static Mutex<()> {
&crate::test_support::CWD_TEST_LOCK
}
struct EnvScope {
_guard: std::sync::MutexGuard<'static, ()>,
values: Vec<(&'static str, Option<OsString>)>,
}
impl EnvScope {
fn set(values: &[(&'static str, Option<&std::ffi::OsStr>)]) -> Self {
let guard = crate::test_support::ENV_TEST_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let previous = values
.iter()
.map(|(key, _)| (*key, std::env::var_os(key)))
.collect::<Vec<_>>();
for (key, value) in values {
unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
Self {
_guard: guard,
values: previous,
}
}
}
impl Drop for EnvScope {
fn drop(&mut self) {
for (key, value) in self.values.drain(..) {
unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
}
#[test]
fn infers_agent_from_command_or_uses_override() {
let command = RunCommand {
agent: None,
config: None,
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config_path: None,
dry_run: false,
print: false,
command: vec!["/usr/bin/codex".into()],
};
let (agent, argv) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap();
assert_eq!(agent, CodingAgent::Codex);
assert_eq!(argv, vec!["/usr/bin/codex"]);
let command = RunCommand {
agent: Some(CodingAgent::ClaudeCode),
command: vec!["wrapper".into()],
..command
};
let (agent, _) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap();
assert_eq!(agent, CodingAgent::ClaudeCode);
}
#[test]
fn uses_configured_command_when_no_argv_is_supplied() {
let agents = AgentConfigs {
codex: AgentCommandConfig {
command: Some("codex --full-auto".into()),
hooks_path: None,
},
..AgentConfigs::default()
};
let command = RunCommand {
agent: Some(CodingAgent::Codex),
config: None,
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config_path: None,
dry_run: false,
print: false,
command: vec![],
};
let (agent, argv) = resolve_agent_and_argv(&command, &agents).unwrap();
assert_eq!(agent, CodingAgent::Codex);
assert_eq!(argv, vec!["codex", "--full-auto"]);
}
#[test]
fn uses_configured_hermes_command_when_no_argv_is_supplied() {
let agents = AgentConfigs {
hermes: AgentCommandConfig {
command: Some("hermes --yolo chat".into()),
hooks_path: None,
},
..AgentConfigs::default()
};
let command = RunCommand {
agent: Some(CodingAgent::Hermes),
config: None,
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config_path: None,
dry_run: false,
print: false,
command: vec![],
};
let (agent, argv) = resolve_agent_and_argv(&command, &agents).unwrap();
assert_eq!(agent, CodingAgent::Hermes);
assert_eq!(argv, vec!["hermes", "--yolo", "chat"]);
}
#[test]
fn inference_failure_has_actionable_message() {
let command = RunCommand {
agent: None,
config: None,
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config_path: None,
dry_run: false,
print: false,
command: vec!["my-agent".into()],
};
let error = resolve_agent_and_argv(&command, &AgentConfigs::default())
.unwrap_err()
.to_string();
assert!(error.contains("pass --agent claude"));
}
#[test]
fn missing_command_without_agent_errors() {
let command = RunCommand {
agent: None,
config: None,
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config_path: None,
dry_run: false,
print: false,
command: vec![],
};
let error = resolve_agent_and_argv(&command, &AgentConfigs::default())
.unwrap_err()
.to_string();
assert!(error.contains("missing command"));
}
#[test]
fn agent_without_configured_command_falls_back_to_default_binary() {
let command = RunCommand {
agent: Some(CodingAgent::Hermes),
config: None,
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config_path: None,
dry_run: false,
print: false,
command: vec![],
};
let (agent, argv) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap();
assert_eq!(agent, CodingAgent::Hermes);
assert_eq!(argv, vec!["hermes"]);
}
#[test]
fn agent_with_passthrough_args_appends_to_configured_command() {
let command = RunCommand {
agent: Some(CodingAgent::Codex),
config: None,
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config_path: None,
dry_run: false,
print: false,
command: vec!["--model".into(), "openai/openai/gpt-5.1-codex".into()],
};
let (_, argv) = resolve_agent_and_argv(&command, &AgentConfigs::default()).unwrap();
assert_eq!(
argv,
vec!["codex", "--model", "openai/openai/gpt-5.1-codex"]
);
}
#[test]
fn default_and_configured_command_helpers_cover_empty_and_all_agents() {
assert_eq!(default_command_for(CodingAgent::ClaudeCode), "claude");
assert_eq!(default_command_for(CodingAgent::Codex), "codex");
assert_eq!(default_command_for(CodingAgent::Hermes), "hermes");
let agents = AgentConfigs {
codex: AgentCommandConfig {
command: Some(" ".into()),
hooks_path: None,
},
..AgentConfigs::default()
};
assert!(configured_command(CodingAgent::Codex, &agents).is_none());
}
#[test]
fn prepares_codex_config_overrides() {
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
agents: AgentConfigs::default(),
..ResolvedConfig::default()
};
let prepared = PreparedRun::new(
CodingAgent::Codex,
vec!["codex".into()],
"http://127.0.0.1:1234",
&resolved,
false,
)
.unwrap();
assert!(prepared.argv.contains(&"features.hooks=true".into()));
assert!(
prepared
.argv
.iter()
.any(|arg| arg == "model_provider=\"nemo-relay-openai\"")
);
assert!(
prepared
.argv
.iter()
.any(|arg| arg.contains("model_providers.nemo-relay-openai")
&& arg.contains("base_url=\"http://127.0.0.1:1234\"")
&& arg.contains("requires_openai_auth=true")
&& arg.contains("supports_websockets=false"))
);
assert!(
!prepared
.argv
.iter()
.any(|arg| arg.contains("model_providers.openai"))
);
assert!(
prepared
.argv
.iter()
.any(|arg| arg.contains("hooks.SessionStart"))
);
let path = prepared
.env
.iter()
.find_map(|(name, value)| (name == "PATH").then_some(value))
.expect("transparent run should set PATH for hook subprocesses");
let current_exe_dir = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let entries = std::env::split_paths(path).collect::<Vec<_>>();
assert!(entries.iter().any(|entry| entry == ¤t_exe_dir));
if !std::env::var_os("PATH")
.as_deref()
.map(std::env::split_paths)
.into_iter()
.flatten()
.any(|entry| entry == current_exe_dir)
{
assert_eq!(entries.last(), Some(¤t_exe_dir));
}
}
#[test]
fn prepares_codex_with_hooks_when_auth_missing() {
let _guard = current_dir_lock().lock().unwrap();
let temp = tempfile::tempdir().unwrap();
let _env = EnvScope::set(&[
("OPENAI_API_KEY", None),
("HOME", Some(temp.path().as_os_str())),
("USERPROFILE", None),
]);
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
agents: AgentConfigs::default(),
..ResolvedConfig::default()
};
let prepared = PreparedRun::new(
CodingAgent::Codex,
vec!["codex".into()],
"http://127.0.0.1:1234",
&resolved,
false,
)
.unwrap();
assert!(prepared.argv.iter().any(|arg| arg == "features.hooks=true"));
}
#[test]
fn exporter_destinations_describe_observability_outputs() {
let gateway = GatewayConfig {
plugin_config: Some(json!({
"version": 1,
"components": [{
"kind": OBSERVABILITY_PLUGIN_KIND,
"enabled": true,
"config": {
"version": 1,
"atof": {
"enabled": true,
"output_directory": "logs",
"filename": "events.jsonl"
},
"atif": {
"enabled": true,
"output_directory": "trajectories",
"filename_template": "agent-{session_id}.json"
},
"opentelemetry": {
"enabled": true,
"endpoint": "http://127.0.0.1:4318/v1/traces"
},
"openinference": {
"enabled": true
}
}
}]
})),
..GatewayConfig::default()
};
let destinations = exporter_destinations(&gateway);
assert!(destinations.iter().any(|line| line
== &format!(
"ATOF {}",
PathBuf::from("logs").join("events.jsonl").display()
)));
assert!(destinations.iter().any(|line| line
== &format!(
"ATIF {}",
PathBuf::from("trajectories")
.join("agent-{session_id}.json")
.display()
)));
assert!(
destinations
.iter()
.any(|line| line == "OpenTelemetry http://127.0.0.1:4318/v1/traces")
);
assert!(
destinations
.iter()
.any(|line| line == "OpenInference OTLP endpoint from environment/default")
);
}
#[test]
fn exporter_destinations_describe_atif_remote_storage_instead_of_local_path() {
let gateway = GatewayConfig {
plugin_config: Some(json!({
"version": 1,
"components": [{
"kind": OBSERVABILITY_PLUGIN_KIND,
"enabled": true,
"config": {
"version": 1,
"atif": {
"enabled": true,
"output_directory": "trajectories",
"filename_template": "agent-{session_id}.json",
"storage": [
{"type": "s3", "bucket": "traj-bucket", "key_prefix": "runs/"},
{"type": "http", "endpoint": "https://collector.example/ingest"}
]
}
}
}]
})),
..GatewayConfig::default()
};
let destinations = exporter_destinations(&gateway);
assert!(
destinations
.iter()
.any(|line| line == "ATIF s3://traj-bucket/runs")
);
assert!(
destinations
.iter()
.any(|line| line == "ATIF https://collector.example/ingest")
);
assert!(
!destinations
.iter()
.any(|line| line.contains("agent-{session_id}.json"))
);
}
#[test]
fn exporter_destinations_cover_invalid_disabled_and_missing_plugin_configs() {
let invalid_plugin = GatewayConfig {
plugin_config: Some(json!({"components": "not-a-list"})),
..GatewayConfig::default()
};
assert_eq!(
exporter_destinations(&invalid_plugin),
vec!["configured (invalid plugin config)".to_string()]
);
let disabled_observability = GatewayConfig {
plugin_config: Some(json!({
"version": 1,
"components": [{
"kind": OBSERVABILITY_PLUGIN_KIND,
"enabled": false,
"config": {"version": 1}
}]
})),
..GatewayConfig::default()
};
assert!(exporter_destinations(&disabled_observability).is_empty());
let invalid_observability = GatewayConfig {
plugin_config: Some(json!({
"version": 1,
"components": [{
"kind": OBSERVABILITY_PLUGIN_KIND,
"enabled": true,
"config": {"version": "bad"}
}]
})),
..GatewayConfig::default()
};
assert_eq!(
exporter_destinations(&invalid_observability),
vec!["Observability configured (invalid config)".to_string()]
);
assert!(exporter_destinations(&GatewayConfig::default()).is_empty());
}
#[test]
fn insert_after_agent_uses_last_matching_agent_or_first_word_fallback() {
let mut argv = vec![
"wrapper".to_string(),
"codex".to_string(),
"subcommand".to_string(),
"/usr/local/bin/codex".to_string(),
];
insert_after_agent(&mut argv, CodingAgent::Codex, ["--config".to_string()]);
assert_eq!(
argv,
vec![
"wrapper",
"codex",
"subcommand",
"/usr/local/bin/codex",
"--config"
]
);
let mut wrapped = vec!["agent-wrapper".to_string(), "run".to_string()];
insert_after_agent(&mut wrapped, CodingAgent::Hermes, ["--hook".to_string()]);
assert_eq!(wrapped, vec!["agent-wrapper", "--hook", "run"]);
}
#[test]
fn prepares_claude_dry_run_without_writing_plugin() {
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
agents: AgentConfigs::default(),
..ResolvedConfig::default()
};
let prepared = PreparedRun::new(
CodingAgent::ClaudeCode,
vec!["claude".into()],
"http://127.0.0.1:1234",
&resolved,
true,
)
.unwrap();
assert_eq!(prepared.argv[1], "--plugin-dir");
assert_eq!(prepared.argv[2], "<temporary-claude-plugin-dir>");
assert!(
prepared
.env
.contains(&("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:1234".into()))
);
assert!(prepared.notes[0].contains("would generate"));
}
#[test]
fn prepares_claude_dry_inserts_plugin_dir_after_last_agent_executable() {
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
agents: AgentConfigs::default(),
..ResolvedConfig::default()
};
let prepared = PreparedRun::new(
CodingAgent::ClaudeCode,
vec![
"wrapper".into(),
"claude".into(),
"subcommand".into(),
"/opt/bin/claude".into(),
"--resume".into(),
],
"http://127.0.0.1:1234",
&resolved,
true,
)
.unwrap();
let plugin_index = prepared
.argv
.iter()
.position(|arg| arg == "--plugin-dir")
.expect("plugin dir arg");
assert_eq!(prepared.argv[plugin_index - 1], "/opt/bin/claude");
assert_eq!(
prepared.argv[plugin_index + 1],
"<temporary-claude-plugin-dir>"
);
assert_eq!(prepared.argv.last().map(String::as_str), Some("--resume"));
assert!(prepared.temp_dirs.is_empty());
}
#[test]
fn prepares_hermes_hook_environment() {
let _guard = current_dir_lock().lock().unwrap();
let temp = tempfile::tempdir().unwrap();
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
let hooks_path = temp.path().join("hermes-home/config.yaml");
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
agents: AgentConfigs {
hermes: AgentCommandConfig {
command: None,
hooks_path: Some(hooks_path.clone()),
},
..AgentConfigs::default()
},
dynamic_plugins: Vec::new(),
..ResolvedConfig::default()
};
let prepared = PreparedRun::new(
CodingAgent::Hermes,
vec!["hermes".into(), "chat".into()],
"http://127.0.0.1:1234",
&resolved,
false,
)
.unwrap();
assert_eq!(prepared.argv, vec!["hermes", "chat"]);
assert!(prepared.env.contains(&(
"NEMO_RELAY_GATEWAY_URL".into(),
"http://127.0.0.1:1234".into()
)));
assert!(
prepared
.env
.contains(&("HERMES_ACCEPT_HOOKS".into(), "1".into()))
);
assert_eq!(
prepared
.hermes_restore
.as_ref()
.map(|restore| &restore.path),
Some(&hooks_path)
);
let hooks = std::fs::read_to_string(&hooks_path).unwrap();
assert!(hooks.contains("hook-forward hermes"));
assert!(prepared.notes[0].contains("temporarily merged"));
prepared.restore().unwrap();
assert!(!hooks_path.exists());
std::env::set_current_dir(previous).unwrap();
}
#[test]
fn prepares_hermes_dry_uses_home_path_without_writing_hooks() {
let _guard = current_dir_lock().lock().unwrap();
let temp = tempfile::tempdir().unwrap();
let _env = EnvScope::set(&[
("HERMES_HOME", None),
("HOME", Some(temp.path().as_os_str())),
("USERPROFILE", None),
]);
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
agents: AgentConfigs::default(),
..ResolvedConfig::default()
};
let prepared = PreparedRun::new(
CodingAgent::Hermes,
vec!["hermes".into()],
"http://127.0.0.1:1234",
&resolved,
true,
)
.unwrap();
let hook_path = temp.path().join(".hermes/config.yaml");
assert!(prepared.notes[0].contains(".hermes"));
assert!(prepared.notes[0].contains("config.yaml"));
assert!(
prepared
.env
.contains(&("HERMES_ACCEPT_HOOKS".into(), "1".into()))
);
assert!(!hook_path.exists());
}
#[test]
fn hermes_hooks_path_prefers_configured_then_env_then_home() {
let _guard = current_dir_lock().lock().unwrap();
let temp = tempfile::tempdir().unwrap();
let configured = temp.path().join("configured.yaml");
assert_eq!(hermes_hooks_path(Some(&configured)).unwrap(), configured);
let _env = EnvScope::set(&[
("HERMES_HOME", Some(temp.path().as_os_str())),
("HOME", None),
("USERPROFILE", None),
]);
assert_eq!(
hermes_hooks_path(None).unwrap(),
temp.path().join("config.yaml")
);
drop(_env);
let _env = EnvScope::set(&[
("HERMES_HOME", None),
("HOME", Some(temp.path().as_os_str())),
("USERPROFILE", None),
]);
assert_eq!(
hermes_hooks_path(None).unwrap(),
temp.path().join(".hermes/config.yaml")
);
drop(_env);
let _env = EnvScope::set(&[("HERMES_HOME", None), ("HOME", None), ("USERPROFILE", None)]);
let error = hermes_hooks_path(None).unwrap_err().to_string();
assert!(error.contains("could not resolve home directory"));
}
#[test]
fn hermes_patch_restore_restores_original_file() {
let _guard = current_dir_lock().lock().unwrap();
let temp = tempfile::tempdir().unwrap();
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
let hooks_path = temp.path().join("hermes-home/config.yaml");
std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap();
let original = "hooks:\n PreToolUse: []\n";
std::fs::write(&hooks_path, original).unwrap();
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
agents: AgentConfigs {
hermes: AgentCommandConfig {
command: None,
hooks_path: Some(hooks_path.clone()),
},
..AgentConfigs::default()
},
..ResolvedConfig::default()
};
let prepared = PreparedRun::new(
CodingAgent::Hermes,
vec!["hermes".into(), "chat".into()],
"http://s",
&resolved,
false,
)
.unwrap();
assert!(
std::fs::read_to_string(&hooks_path)
.unwrap()
.contains("hook-forward hermes")
);
prepared.restore().unwrap();
assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original);
std::env::set_current_dir(previous).unwrap();
}
#[test]
fn prepares_claude_temp_plugin() {
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
agents: AgentConfigs::default(),
..ResolvedConfig::default()
};
let prepared = PreparedRun::new(
CodingAgent::ClaudeCode,
vec!["claude".into()],
"http://127.0.0.1:1234",
&resolved,
false,
)
.unwrap();
let plugin_index = prepared
.argv
.iter()
.position(|arg| arg == "--plugin-dir")
.unwrap();
let plugin_dir = PathBuf::from(&prepared.argv[plugin_index + 1]);
assert!(plugin_dir.join("hooks/hooks.json").exists());
assert!(
prepared
.env
.contains(&("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:1234".into()))
);
prepared.restore().unwrap();
}
#[test]
fn hermes_restore_reports_restore_and_temporary_removal_failures() {
let temp = tempfile::tempdir().unwrap();
let restore_missing_backup = PreparedRun {
argv: vec![],
env: vec![],
temp_dirs: vec![],
hermes_restore: Some(HermesRestore {
path: temp.path().join("config.yaml"),
backup_path: Some(temp.path().join("missing-backup.yaml")),
had_original: true,
}),
notes: vec![],
};
let error = restore_missing_backup.restore().unwrap_err().to_string();
assert!(error.contains("failed to restore Hermes hooks"));
let hooks_path = temp.path().join("hooks-dir");
std::fs::create_dir(&hooks_path).unwrap();
let remove_temporary_dir = PreparedRun {
argv: vec![],
env: vec![],
temp_dirs: vec![],
hermes_restore: Some(HermesRestore {
path: hooks_path,
backup_path: None,
had_original: false,
}),
notes: vec![],
};
let error = remove_temporary_dir.restore().unwrap_err().to_string();
assert!(error.contains("failed to remove temporary Hermes hooks"));
}
#[test]
fn hook_backup_and_write_helpers_cover_missing_existing_and_toml_escaping() {
let temp = tempfile::tempdir().unwrap();
let missing_hermes = temp.path().join("missing-config.yaml");
assert_eq!(
backup_existing_hermes_hooks(&missing_hermes).unwrap(),
(false, None)
);
let hermes_hooks = temp.path().join("config.yaml");
std::fs::write(&hermes_hooks, "hooks: {}\n").unwrap();
let (had_original, hermes_backup) = backup_existing_hermes_hooks(&hermes_hooks).unwrap();
assert!(had_original);
assert!(hermes_backup.as_ref().unwrap().exists());
let written_hooks = temp.path().join("written/hooks.json");
std::fs::create_dir_all(written_hooks.parent().unwrap()).unwrap();
write_hooks(&written_hooks, json!({"hooks": []})).unwrap();
assert!(
std::fs::read_to_string(&written_hooks)
.unwrap()
.contains("hooks")
);
let groups = hook_groups_toml(&json!([{
"matcher": "Shell\"Run",
"hooks": [{"command": "nemo-relay \"quoted\""}]
}]));
assert!(groups.contains("matcher=\"Shell\\\"Run\""));
assert!(groups.contains("command=\"nemo-relay \\\"quoted\\\"\""));
let escaped = toml_string(r#"C:\tmp\"quoted""#);
assert!(escaped.starts_with('"'));
assert!(escaped.ends_with('"'));
assert!(escaped.contains(r#"C:\\tmp\\"#));
assert!(escaped.contains(r#"\"quoted\""#));
}
#[cfg(unix)]
#[test]
fn exit_code_preserves_normal_and_shell_wrapped_codes() {
let status = std::process::Command::new("/bin/sh")
.args(["-c", "exit 7"])
.status()
.unwrap();
assert_eq!(exit_code(status), ExitCode::from(7));
let status = std::process::Command::new("/bin/sh")
.args(["-c", "exit 300"])
.status()
.unwrap();
assert_eq!(exit_code(status), ExitCode::from(44));
}
#[cfg(unix)]
#[tokio::test]
async fn run_starts_gateway_injects_env_and_returns_agent_exit_code() {
let temp = tempfile::tempdir().unwrap();
let config = temp.path().join("config.toml");
std::fs::write(&config, "[upstream]\n").unwrap();
let output = temp.path().join("env.txt");
let command_argv = fake_agent_command(temp.path(), &output);
let command = RunCommand {
agent: None,
config: Some(config),
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config_path: None,
dry_run: false,
print: false,
command: command_argv,
};
let code = run(command, None).await.unwrap();
assert_eq!(code, ExitCode::from(7));
let url = std::fs::read_to_string(output).unwrap();
assert!(url.starts_with("http://127.0.0.1:"));
assert!(!url.ends_with(":0"));
}
#[cfg(unix)]
fn fake_agent_command(temp: &Path, output: &Path) -> Vec<String> {
let script = temp.join("codex");
std::fs::write(
&script,
format!(
"#!/bin/sh\nprintf '%s' \"$NEMO_RELAY_GATEWAY_URL\" > \"{}\"\nexit 7\n",
output.display()
),
)
.unwrap();
make_executable(&script);
vec![script.display().to_string()]
}
#[tokio::test]
async fn dry_run_does_not_spawn_agent() {
let command = RunCommand {
agent: Some(CodingAgent::Codex),
config: None,
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config_path: None,
dry_run: true,
print: false,
command: vec!["/path/that/does/not/exist".into()],
};
let code = run(command, None).await.unwrap();
assert_eq!(code, ExitCode::SUCCESS);
}
#[tokio::test]
async fn dry_run_does_not_hydrate_dynamic_plugin_lifecycle_state() {
let temp = tempfile::tempdir().unwrap();
let plugin_dir = temp.path().join("plugins/acme");
std::fs::create_dir_all(&plugin_dir).unwrap();
let manifest_path = plugin_dir.join("relay-plugin.toml");
std::fs::write(
&manifest_path,
format!(
r#"
manifest_version = 1
[plugin]
id = "acme.worker"
kind = "worker"
[compat]
relay = "={version}"
worker_protocol = "grpc-v1"
[capabilities]
items = ["plugin_worker"]
[defaults]
[load]
runtime = "python"
entrypoint = "acme.worker:create_plugin"
"#,
version = env!("CARGO_PKG_VERSION"),
),
)
.unwrap();
let config_path = temp.path().join("config.toml");
std::fs::write(&config_path, "").unwrap();
std::fs::write(
temp.path().join("plugins.toml"),
format!(
"[[plugins.dynamic]]\nmanifest = {:?}\n",
manifest_path.to_string_lossy()
),
)
.unwrap();
let command = RunCommand {
agent: Some(CodingAgent::Codex),
config: Some(config_path),
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config_path: None,
dry_run: true,
print: false,
command: vec!["codex".into()],
};
let code = run(command, None).await.unwrap();
assert_eq!(code, ExitCode::SUCCESS);
assert!(!temp.path().join(".dynamic-plugins.json").exists());
}
#[tokio::test]
async fn wait_for_health_reports_unready_gateway() {
let error = wait_for_health("http://127.0.0.1:1")
.await
.unwrap_err()
.to_string();
assert!(error.contains("gateway did not become ready"));
}
#[tokio::test]
async fn execute_live_run_reports_gateway_startup_error_when_health_check_fails() {
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
agents: AgentConfigs::default(),
..ResolvedConfig::default()
};
let prepared = PreparedRun::new(
CodingAgent::ClaudeCode,
vec!["claude".into()],
"http://127.0.0.1:1234",
&resolved,
false,
)
.unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let gateway_url = format!("http://{}", listener.local_addr().unwrap());
let gateway_config = GatewayConfig {
plugin_config: Some(json!({
"version": 1,
"components": [{
"kind": OBSERVABILITY_PLUGIN_KIND,
"enabled": true,
"config": {
"version": 1,
"atof": {
"enabled": true,
"mode": "invalid"
}
}
}]
})),
..GatewayConfig::default()
};
let error = execute_live_run(listener, gateway_config, &gateway_url, prepared)
.await
.unwrap_err()
.to_string();
assert!(error.contains("ATOF mode"));
assert!(!error.contains("gateway did not become ready"));
}
#[tokio::test]
async fn execute_live_run_restores_hermes_hooks_when_health_check_fails() {
let temp = tempfile::tempdir().unwrap();
let hooks_path = temp.path().join("hermes-home/config.yaml");
std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap();
let original = "hooks:\n PreToolUse: []\n";
std::fs::write(&hooks_path, original).unwrap();
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
agents: AgentConfigs {
hermes: AgentCommandConfig {
command: None,
hooks_path: Some(hooks_path.clone()),
},
..AgentConfigs::default()
},
..ResolvedConfig::default()
};
let prepared = PreparedRun::new(
CodingAgent::Hermes,
vec!["hermes".into(), "chat".into()],
"http://127.0.0.1:1234",
&resolved,
false,
)
.unwrap();
assert!(
std::fs::read_to_string(&hooks_path)
.unwrap()
.contains("hook-forward hermes")
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let error = execute_live_run(
listener,
GatewayConfig::default(),
"http://127.0.0.1:1",
prepared,
)
.await
.unwrap_err()
.to_string();
assert!(error.contains("gateway did not become ready"));
assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original);
}
#[cfg(unix)]
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).unwrap();
}