diff --git a/crates/cli/src/agents/claude/launch.rs b/crates/cli/src/agents/claude/launch.rs index 4c67e89cc..b9ec83aca 100644 --- a/crates/cli/src/agents/claude/launch.rs +++ b/crates/cli/src/agents/claude/launch.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use crate::agents::CodingAgent; use crate::error::CliError; -use crate::hooks::{generated_policy_hooks, transparent_hook_forward_commands}; +use crate::hooks::{generated_policy_hooks, transparent_hook_forward_commands_with_config}; use crate::process::{PreparedAgentLaunch, insert_after_host}; pub(crate) fn prepare( @@ -64,10 +64,14 @@ pub(crate) fn prepare( })) .map_err(|error| CliError::Launch(error.to_string()))?, )?; - let hook_commands = transparent_hook_forward_commands( + let hook_config = root.join(".nemo-relay-hook-config.json"); + crate::hooks::HookCommandConfig::transparent(CodingAgent::ClaudeCode, gateway_url) + .write(&hook_config) + .map_err(CliError::Launch)?; + let hook_commands = transparent_hook_forward_commands_with_config( &transparent_hook_executable(), CodingAgent::ClaudeCode, - gateway_url, + &hook_config, ) .map_err(CliError::Launch)?; write_hooks( diff --git a/crates/cli/src/agents/codex/host.rs b/crates/cli/src/agents/codex/host.rs index d1794d5d0..22e7a8f8b 100644 --- a/crates/cli/src/agents/codex/host.rs +++ b/crates/cli/src/agents/codex/host.rs @@ -1789,13 +1789,13 @@ pub(crate) fn codex_hook_command(gateway_url: &str) -> String { pub(crate) fn codex_plugin_hook_command( relay: &Path, generation: &Path, - generation_token: &str, + _generation_token: &str, ) -> Result { crate::hooks::persistent_hook_forward_commands( relay, CodingAgent::Codex, generation, - generation_token, + _generation_token, ) } @@ -1803,14 +1803,14 @@ pub(crate) fn codex_plugin_hook_command( pub(crate) fn codex_plugin_hook_command_for_platform( relay: &Path, generation: &Path, - generation_token: &str, + _generation_token: &str, windows: bool, ) -> crate::hooks::GeneratedHookCommands { crate::hooks::persistent_hook_forward_commands_for_platform( relay, CodingAgent::Codex, generation, - generation_token, + _generation_token, windows, ) } diff --git a/crates/cli/src/agents/codex/launch.rs b/crates/cli/src/agents/codex/launch.rs index 58676f8bc..d2957aa65 100644 --- a/crates/cli/src/agents/codex/launch.rs +++ b/crates/cli/src/agents/codex/launch.rs @@ -8,7 +8,7 @@ use serde_json::Value; use crate::agents::CodingAgent; use crate::configuration::{RELAY_PLUGIN_ID, RELAY_SOURCE_PLUGIN_ID}; use crate::error::CliError; -use crate::hooks::{generated_policy_hooks, transparent_hook_forward_commands}; +use crate::hooks::{generated_policy_hooks, transparent_hook_forward_commands_with_config}; use crate::process::{PreparedAgentLaunch, insert_after_host}; pub(crate) fn prepare(launch: &mut PreparedAgentLaunch, gateway_url: &str) -> Result<(), CliError> { @@ -26,10 +26,16 @@ pub(crate) fn prepare(launch: &mut PreparedAgentLaunch, gateway_url: &str) -> Re or pass `--openai-base-url` to an upstream that needs no key." ); } - let hook_commands = transparent_hook_forward_commands( + let hook_root = temp_dir("nemo-relay-codex-hooks")?; + let hook_config = hook_root.join(".nemo-relay-hook-config.json"); + crate::hooks::HookCommandConfig::transparent(CodingAgent::Codex, gateway_url) + .write(&hook_config) + .map_err(CliError::Launch)?; + launch.temp_dirs.push(hook_root); + let hook_commands = transparent_hook_forward_commands_with_config( &transparent_hook_executable(), CodingAgent::Codex, - gateway_url, + &hook_config, ) .map_err(CliError::Launch)?; let hook_groups = generated_policy_hooks(CodingAgent::Codex, &hook_commands); @@ -233,3 +239,9 @@ fn transparent_hook_executable() -> PathBuf { .map(crate::agents::portable_executable_path) .unwrap_or_else(|_| PathBuf::from("nemo-relay")) } + +fn temp_dir(prefix: &str) -> Result { + let path = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path)?; + Ok(path) +} diff --git a/crates/cli/src/agents/mod.rs b/crates/cli/src/agents/mod.rs index 133b8ad98..7cc247fd0 100644 --- a/crates/cli/src/agents/mod.rs +++ b/crates/cli/src/agents/mod.rs @@ -181,11 +181,25 @@ impl crate::installation::marketplace::MarketplaceHost for CodingAgent { plugin_mcp_config(self, server) } + fn persistent_hook_config( + self, + generation_fence: &std::path::Path, + generation_token: &str, + ) -> crate::hooks::HookCommandConfig { + crate::hooks::HookCommandConfig::persistent( + self, + crate::bootstrap::DEFAULT_URL, + generation_fence.to_owned(), + generation_token, + ) + } + fn plugin_hooks( self, relay: &std::path::Path, generation_fence: &std::path::Path, generation_token: &str, + _hook_config: &std::path::Path, ) -> Result { let commands = crate::hooks::persistent_hook_forward_commands( relay, @@ -674,7 +688,7 @@ fn failed_integration_readiness( } pub(crate) use crate::process::portable_executable_path; -#[cfg(any(not(windows), test))] +#[cfg(test)] pub(crate) use crate::process::shell_quote_arg_for_platform; #[cfg(test)] pub(crate) use crate::process::strip_windows_verbatim_prefix; diff --git a/crates/cli/src/commands/hook_forward.rs b/crates/cli/src/commands/hook_forward.rs index 14fdf5770..2deb5e455 100644 --- a/crates/cli/src/commands/hook_forward.rs +++ b/crates/cli/src/commands/hook_forward.rs @@ -13,6 +13,22 @@ pub(crate) struct HookForwardCommand { /// Coding agent whose canonical lifecycle payload is read from standard input. #[arg(value_enum)] pub(crate) agent: AgentArg, + /// Private Relay-owned configuration used by generated coding-agent hooks. + #[arg( + long, + hide = true, + conflicts_with_all = [ + "gateway_url", + "generation_file", + "generation_token", + "forward_only", + "transparent_run", + "profile", + "session_metadata", + "gateway_mode" + ] + )] + pub(crate) hook_config: Option, /// Base URL of the Relay gateway that receives the lifecycle payload. #[arg(long)] pub(crate) gateway_url: Option, @@ -56,6 +72,7 @@ impl HookForwardCommand { fn into_runtime(self) -> crate::hooks::HookForwardRequest { crate::hooks::HookForwardRequest { agent: self.agent.into(), + hook_config: self.hook_config, gateway_url: self.gateway_url, generation_file: self.generation_file, generation_token: self.generation_token, diff --git a/crates/cli/src/hooks/config.rs b/crates/cli/src/hooks/config.rs new file mode 100644 index 000000000..a8ebee3b6 --- /dev/null +++ b/crates/cli/src/hooks/config.rs @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Private, installer-owned configuration for generated coding-agent hooks. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::agents::CodingAgent; + +use super::{GatewayMode, HookForwardRequest}; + +const HOOK_CONFIG_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct HookCommandConfig { + version: u32, + agent: String, + gateway_url: String, + generation_file: Option, + generation_token: Option, + forward_only: bool, + transparent_run: bool, + profile: Option, + session_metadata: Option, + gateway_mode: Option, +} + +impl HookCommandConfig { + pub(crate) fn persistent( + agent: CodingAgent, + gateway_url: impl Into, + generation_file: PathBuf, + generation_token: impl Into, + ) -> Self { + Self { + version: HOOK_CONFIG_VERSION, + agent: agent.as_arg().into(), + gateway_url: gateway_url.into(), + generation_file: Some(generation_file), + generation_token: Some(generation_token.into()), + forward_only: false, + transparent_run: false, + profile: None, + session_metadata: None, + gateway_mode: None, + } + } + + pub(crate) fn transparent(agent: CodingAgent, gateway_url: impl Into) -> Self { + Self { + version: HOOK_CONFIG_VERSION, + agent: agent.as_arg().into(), + gateway_url: gateway_url.into(), + generation_file: None, + generation_token: None, + forward_only: false, + transparent_run: true, + profile: None, + session_metadata: None, + gateway_mode: None, + } + } + + pub(crate) fn write(&self, path: &Path) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(self) + .map_err(|error| format!("failed to serialize hook configuration: {error}"))?; + crate::filesystem::atomic_write_private(path, &bytes) + } + + pub(crate) fn load(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|error| { + format!( + "failed to read hook configuration {}: {error}", + path.display() + ) + })?; + let config = serde_json::from_slice::(&bytes).map_err(|error| { + format!( + "failed to parse hook configuration {}: {error}", + path.display() + ) + })?; + config.validate()?; + Ok(config) + } + + pub(crate) fn apply(self, request: &mut HookForwardRequest) -> Result<(), String> { + if self.agent != request.agent.as_arg() { + return Err(format!( + "hook configuration is for {} but the command requested {}", + self.agent, + request.agent.as_arg() + )); + } + if request.has_inline_configuration() { + return Err( + "--hook-config cannot be combined with inline hook configuration options".into(), + ); + } + request.gateway_url = Some(self.gateway_url); + request.generation_file = self.generation_file; + request.generation_token = self.generation_token; + request.forward_only = self.forward_only; + request.transparent_run = self.transparent_run; + request.profile = self.profile; + request.session_metadata = self.session_metadata; + request.gateway_mode = self.gateway_mode; + Ok(()) + } + + fn validate(&self) -> Result<(), String> { + if self.version != HOOK_CONFIG_VERSION { + return Err(format!( + "unsupported hook configuration version {}; expected {HOOK_CONFIG_VERSION}", + self.version + )); + } + if self.agent.trim().is_empty() || self.gateway_url.trim().is_empty() { + return Err("hook configuration requires an agent and gateway URL".into()); + } + if self.generation_file.is_some() != self.generation_token.is_some() { + return Err("hook configuration must include both generation file and token".into()); + } + if self.forward_only && (self.generation_file.is_some() || self.transparent_run) { + return Err("forward-only hook configuration cannot include a generation fence or transparent mode".into()); + } + if self.transparent_run && self.generation_file.is_some() { + return Err("transparent hook configuration cannot include a generation fence".into()); + } + Ok(()) + } +} diff --git a/crates/cli/src/hooks/delivery.rs b/crates/cli/src/hooks/delivery.rs index 232c362c8..db1578fb3 100644 --- a/crates/cli/src/hooks/delivery.rs +++ b/crates/cli/src/hooks/delivery.rs @@ -20,16 +20,25 @@ use super::{GatewayMode, HookForwardRequest}; const HOOK_FORWARD_TIMEOUT: Duration = Duration::from_secs(2); -pub(crate) async fn hook_forward(command: HookForwardRequest) -> Result<(), CliError> { +pub(crate) async fn hook_forward(mut command: HookForwardRequest) -> Result<(), CliError> { + let fail_closed = command.failure_policy.fail_closed(); + if let Some(path) = command.hook_config.clone() + && let Err(error) = + super::HookCommandConfig::load(&path).and_then(|config| config.apply(&mut command)) + { + return handle_hook_error(CliError::Launch(error), fail_closed); + } + if let Err(error) = + validate_optional_json("session metadata", command.session_metadata.as_deref()) + { + return handle_hook_error(error, fail_closed); + } // A transparent wrapper can coexist with any installed Relay plugin. Its process marker makes // persistent plugin hooks inert, while only the wrapper-owned command carries - // `--transparent-run` and forwards to the process-private gateway. This avoids rewriting host - // plugin settings and works for both installer and source-marketplace plugin identities. + // `--transparent-run` and forwards to the process-private gateway. if transparent_run_active() && !command.transparent_run { return Ok(()); } - validate_optional_json("session metadata", command.session_metadata.as_deref())?; - let fail_closed = command.failure_policy.fail_closed(); let destination = hook_destination(&command); let persistent = match persistent_gateway(&destination) { Ok(persistent) => persistent, diff --git a/crates/cli/src/hooks/encoding.rs b/crates/cli/src/hooks/encoding.rs index a704f2677..8dbc5ea88 100644 --- a/crates/cli/src/hooks/encoding.rs +++ b/crates/cli/src/hooks/encoding.rs @@ -9,9 +9,6 @@ use serde_json::{Value, json}; use crate::agents::CodingAgent; -#[cfg(any(windows, test))] -use base64::Engine; - #[cfg(test)] pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { generated_policy_hooks(agent, &GeneratedHookCommands::new(command, command)) @@ -53,27 +50,33 @@ pub(crate) fn generated_policy_hooks( grouped_hooks(agent.hook_events(), commands) } -/// Canonical persistent hook command used by every supported host. pub(crate) fn persistent_hook_forward_commands( relay: &Path, agent: CodingAgent, generation_file: &Path, - generation_token: &str, + _generation_token: &str, ) -> Result { hook_commands( relay, - &persistent_hook_arguments(agent, generation_file, generation_token), + &hook_config_arguments(agent, &config_path(generation_file)), ) } -/// Canonical transparent hook command. It embeds the process-private dynamic gateway so hook hosts -/// that filter inherited environment variables cannot redirect delivery to the fixed endpoint. +#[cfg(test)] pub(crate) fn transparent_hook_forward_commands( relay: &Path, agent: CodingAgent, gateway_url: &str, ) -> Result { - hook_commands(relay, &transparent_hook_arguments(agent, gateway_url)) + hook_commands(relay, &hook_config_arguments(agent, Path::new(gateway_url))) +} + +pub(crate) fn transparent_hook_forward_commands_with_config( + relay: &Path, + agent: CodingAgent, + hook_config: &Path, +) -> Result { + hook_commands(relay, &hook_config_arguments(agent, hook_config)) } #[cfg(test)] @@ -85,7 +88,7 @@ pub(crate) fn transparent_hook_forward_commands_for_platform( ) -> GeneratedHookCommands { hook_commands_for_platform( relay, - &transparent_hook_arguments(agent, gateway_url), + &hook_config_arguments(agent, Path::new(gateway_url)), windows, ) } @@ -95,40 +98,26 @@ pub(crate) fn persistent_hook_forward_commands_for_platform( relay: &Path, agent: CodingAgent, generation_file: &Path, - generation_token: &str, + _generation_token: &str, windows: bool, ) -> GeneratedHookCommands { hook_commands_for_platform( relay, - &persistent_hook_arguments(agent, generation_file, generation_token), + &hook_config_arguments(agent, &config_path(generation_file)), windows, ) } -pub(super) fn transparent_hook_arguments(agent: CodingAgent, gateway_url: &str) -> Vec { - vec![ - "hook-forward".into(), - agent.as_arg().into(), - "--gateway-url".into(), - gateway_url.into(), - "--transparent-run".into(), - ] +fn config_path(generation_file: &Path) -> std::path::PathBuf { + generation_file.with_file_name(".nemo-relay-hook-config.json") } -pub(super) fn persistent_hook_arguments( - agent: CodingAgent, - generation_file: &Path, - generation_token: &str, -) -> Vec { +pub(super) fn hook_config_arguments(agent: CodingAgent, hook_config: &Path) -> Vec { vec![ "hook-forward".into(), agent.as_arg().into(), - "--gateway-url".into(), - crate::bootstrap::DEFAULT_URL.into(), - "--generation-file".into(), - generation_file.display().to_string(), - "--generation-token".into(), - generation_token.into(), + "--hook-config".into(), + hook_config.display().to_string(), ] } @@ -172,14 +161,15 @@ fn with_failure_policy(arguments: &[String], policy: &str) -> Vec { } pub(super) fn hook_command(relay: &Path, arguments: &[String]) -> Result { + let command = render_hook_command(relay, arguments, cfg!(windows)); #[cfg(windows)] - { - return encoded_windows_hook_command(&windows_powershell_launcher()?, relay, arguments); - } - #[cfg(not(windows))] - { - Ok(posix_hook_command(relay, arguments)) + if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { + return Err(format!( + "generated Windows coding-agent hook command is {} characters and exceeds the {MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS}-character safety limit; shorten the Relay or hook configuration path", + command.encode_utf16().count() + )); } + Ok(command) } #[cfg(test)] @@ -188,193 +178,41 @@ pub(super) fn hook_command_for_platform( arguments: &[String], windows: bool, ) -> String { - if windows { - return encoded_windows_hook_command( - "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", - relay, - arguments, - ) - .expect("test hook command must fit within the Windows command-line limit"); - } - posix_hook_command(relay, arguments) + render_hook_command(relay, arguments, windows) } -#[cfg(any(not(windows), test))] -pub(super) fn posix_hook_command(relay: &Path, arguments: &[String]) -> String { - std::iter::once(relay.display().to_string()) +fn render_hook_command(relay: &Path, arguments: &[String], windows: bool) -> String { + let relay = relay_for_command(relay, windows); + let command = std::iter::once(relay.display().to_string()) .chain(arguments.iter().cloned()) - .map(|argument| crate::agents::shell_quote_arg_for_platform(&argument, false)) - .collect::>() - .join(" ") -} - -// `cmd.exe` accepts at most 8,191 characters. Leave room for `/C` and the executable path added -// by the hook host instead of generating a command that will be truncated at runtime. -#[cfg(any(windows, test))] -const MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS: usize = 8_000; - -/// Encode a native Relay invocation so Windows hook hosts can pass it through `cmd.exe /C` as one -/// argument without corrupting quotes in canonical paths. Windows PowerShell is part of the -/// supported Windows platform; it only launches the Rust binary and preserves its standard I/O. -#[cfg(any(windows, test))] -pub(crate) fn encoded_windows_hook_command( - powershell: &str, - relay: &Path, - arguments: &[String], -) -> Result { - const PREFIX: &str = "$ErrorActionPreference='Stop'; & "; - const SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; - - let invocation = std::iter::once(relay.display().to_string()) - .chain(arguments.iter().cloned()) - .map(|argument| format!("'{}'", argument.replace('\'', "''"))) + .map(|argument| crate::process::shell_quote_arg_for_platform(&argument, windows)) .collect::>() .join(" "); - let script = format!("{PREFIX}{invocation}{SUFFIX}"); - let bytes = script - .encode_utf16() - .flat_map(u16::to_le_bytes) - .collect::>(); - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - let command = - format!("{powershell} -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}"); - if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { - return Err(format!( - "generated Windows coding-agent hook command exceeds the {MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS}-character safety limit; shorten the Relay or plugin installation path" - )); - } - Ok(command) -} - -#[cfg(windows)] -pub(super) fn windows_powershell_launcher() -> Result { - let powershell = windows_powershell_path()?; - if !Path::new(&powershell).is_file() { - return Err(format!( - "trusted Windows PowerShell launcher is missing at {powershell}; install Windows PowerShell before configuring coding-agent hooks" - )); + if windows { + format!("\"{command}\"") + } else { + command } - Ok(powershell) } #[cfg(windows)] -pub(crate) fn windows_powershell_path() -> Result { - use std::os::windows::ffi::OsStringExt; - use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW; - - let mut buffer = vec![0_u16; 260]; - let length = loop { - // SAFETY: `buffer` is writable for its declared length and remains live for the call. - let length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; - if length == 0 { - return Err(format!( - "failed to resolve the trusted Windows system directory: {}", - std::io::Error::last_os_error() - )); - } - if (length as usize) < buffer.len() { - break length as usize; - } - buffer.resize(length as usize + 1, 0); - }; - let system = std::path::PathBuf::from(std::ffi::OsString::from_wide(&buffer[..length])); - let powershell = system.join("WindowsPowerShell/v1.0/powershell.exe"); - let powershell = powershell - .into_os_string() - .into_string() - .map_err(|_| "trusted Windows PowerShell path is not valid Unicode".to_string())? - .replace('\\', "/"); - if !safe_windows_launcher_token(&powershell) { - return Err(format!( - "trusted Windows PowerShell path {powershell} contains characters that cannot be represented safely in coding-agent hook commands" - )); +fn relay_for_command(relay: &Path, windows: bool) -> std::path::PathBuf { + if windows { + crate::process::short_windows_path(relay).unwrap_or_else(|| relay.to_path_buf()) + } else { + relay.to_path_buf() } - Ok(powershell) -} - -#[cfg(any(windows, test))] -pub(super) fn safe_windows_launcher_token(launcher: &str) -> bool { - !launcher.is_empty() - && launcher.chars().all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '/' | ':' | '.' | '_' | '-') - }) - && launcher - .to_ascii_lowercase() - .ends_with("/system32/windowspowershell/v1.0/powershell.exe") } -/// Decode only the exact PowerShell envelope emitted by [`encoded_windows_hook_command`]. -#[cfg(test)] -pub(crate) fn decode_windows_hook_command(command: &str) -> Option> { - const COMMAND_SEPARATOR: &str = " -NoLogo -NoProfile -NonInteractive -EncodedCommand "; - const SCRIPT_PREFIX: &str = "$ErrorActionPreference='Stop'; & "; - const SCRIPT_SUFFIX: &str = "; if ($null -eq $LASTEXITCODE) { exit 1 }; exit $LASTEXITCODE"; - - if command.encode_utf16().count() > MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS { - return None; - } - let (launcher, encoded) = command.split_once(COMMAND_SEPARATOR)?; - if !safe_windows_launcher_token(launcher) { - return None; - } - #[cfg(windows)] - if !launcher.eq_ignore_ascii_case(&windows_powershell_path().ok()?) { - return None; - } - if encoded.is_empty() || encoded.chars().any(char::is_whitespace) { - return None; - } - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .ok()?; - let pairs = bytes.chunks_exact(2); - if !pairs.remainder().is_empty() { - return None; - } - let script = String::from_utf16( - &pairs - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) - .collect::>(), - ) - .ok()?; - let invocation = script - .strip_prefix(SCRIPT_PREFIX)? - .strip_suffix(SCRIPT_SUFFIX)?; - parse_powershell_single_quoted_arguments(invocation) +#[cfg(not(windows))] +fn relay_for_command(relay: &Path, _windows: bool) -> std::path::PathBuf { + relay.to_path_buf() } -#[cfg(test)] -pub(super) fn parse_powershell_single_quoted_arguments(mut raw: &str) -> Option> { - let mut arguments = Vec::new(); - while !raw.is_empty() { - raw = raw.strip_prefix('\'')?; - let mut argument = String::new(); - loop { - let quote = raw.find('\'')?; - argument.push_str(&raw[..quote]); - raw = &raw[quote + 1..]; - if let Some(rest) = raw.strip_prefix('\'') { - argument.push('\''); - raw = rest; - } else { - break; - } - } - arguments.push(argument); - if raw.is_empty() { - break; - } - raw = raw.strip_prefix(' ')?; - if raw.is_empty() { - return None; - } - } - (!arguments.is_empty()).then_some(arguments) -} +// `cmd.exe` accepts at most 8,191 characters. Leave room for `/C` and host-added text. +#[cfg(windows)] +const MAX_WINDOWS_HOOK_COMMAND_UTF16_UNITS: usize = 8_000; -// Generates hook groups for Claude/Codex events and adds a wildcard matcher to tool events when -// the target agent requires matcher-scoped tool hooks. Non-tool events omit matchers so they fire -// for the full lifecycle. fn grouped_hooks(events: &[&str], commands: &GeneratedHookCommands) -> Value { let hooks: serde_json::Map = events .iter() @@ -385,11 +223,7 @@ fn grouped_hooks(events: &[&str], commands: &GeneratedHookCommands) -> Value { } group.insert( "hooks".into(), - json!([{ - "type": "command", - "command": commands.for_event(event), - "timeout": 30 - }]), + json!([{"type": "command", "command": commands.for_event(event), "timeout": 30}]), ); ( (*event).to_string(), @@ -400,8 +234,6 @@ fn grouped_hooks(events: &[&str], commands: &GeneratedHookCommands) -> Value { json!({ "hooks": Value::Object(hooks) }) } -// Identifies hook events that should receive wildcard tool matchers. The list includes current -// Claude/Codex spellings. pub(crate) fn event_matches_tools(event: &str) -> bool { matches!( event, diff --git a/crates/cli/src/hooks/mod.rs b/crates/cli/src/hooks/mod.rs index 5aceb355f..407eae257 100644 --- a/crates/cli/src/hooks/mod.rs +++ b/crates/cli/src/hooks/mod.rs @@ -3,6 +3,7 @@ //! Hook delivery, command encoding, generated definitions, and configuration merging. +mod config; mod delivery; mod destination; mod encoding; @@ -11,6 +12,7 @@ mod merging; mod response; mod types; +pub(crate) use config::HookCommandConfig; pub(crate) use delivery::hook_forward; #[cfg(test)] pub(crate) use delivery::send_verified_hook_forward_request; @@ -21,16 +23,14 @@ pub(crate) use destination::{ HookGatewayLifecycle, resolve_hook_destination, transparent_gateway_spec, }; #[cfg(test)] -pub(crate) use encoding::decode_windows_hook_command; -#[cfg(all(test, windows))] -pub(crate) use encoding::windows_powershell_path; +pub(crate) use encoding::transparent_hook_forward_commands; pub(crate) use encoding::{ GeneratedHookCommands, generated_policy_hooks, persistent_hook_forward_commands, - transparent_hook_forward_commands, + transparent_hook_forward_commands_with_config, }; #[cfg(test)] pub(crate) use encoding::{ - encoded_windows_hook_command, event_matches_tools, event_requires_fail_closed, generated_hooks, + event_matches_tools, event_requires_fail_closed, generated_hooks, persistent_hook_forward_commands_for_platform, transparent_hook_forward_commands_for_platform, }; #[cfg(test)] diff --git a/crates/cli/src/hooks/types.rs b/crates/cli/src/hooks/types.rs index b11713112..45b28e730 100644 --- a/crates/cli/src/hooks/types.rs +++ b/crates/cli/src/hooks/types.rs @@ -8,6 +8,7 @@ use crate::agents::CodingAgent; #[derive(Debug, Clone)] pub(crate) struct HookForwardRequest { pub(crate) agent: CodingAgent, + pub(crate) hook_config: Option, pub(crate) gateway_url: Option, pub(crate) generation_file: Option, pub(crate) generation_token: Option, @@ -19,6 +20,19 @@ pub(crate) struct HookForwardRequest { pub(crate) failure_policy: HookFailurePolicy, } +impl HookForwardRequest { + pub(crate) fn has_inline_configuration(&self) -> bool { + self.gateway_url.is_some() + || self.generation_file.is_some() + || self.generation_token.is_some() + || self.forward_only + || self.transparent_run + || self.profile.is_some() + || self.session_metadata.is_some() + || self.gateway_mode.is_some() + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum HookFailurePolicy { Default, @@ -36,7 +50,8 @@ impl HookFailurePolicy { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] pub(crate) enum GatewayMode { HookOnly, Passthrough, diff --git a/crates/cli/src/installation/marketplace/assets.rs b/crates/cli/src/installation/marketplace/assets.rs index 9de9b2ba4..63c6334ab 100644 --- a/crates/cli/src/installation/marketplace/assets.rs +++ b/crates/cli/src/installation/marketplace/assets.rs @@ -47,6 +47,7 @@ pub(super) fn write_plugin_marketplace_for_generation( println!("write {}", layout.plugin_manifest.display()); println!("write {}", layout.mcp_config.display()); println!("write {}", layout.generation_fence.display()); + println!("write {}", layout.hook_config.display()); println!("write {}", layout.hooks_path.display()); return Ok(()); } @@ -67,13 +68,17 @@ pub(super) fn write_plugin_marketplace_for_generation( } else { write_staged_generation_with_token(&layout.generation_fence, active_generation_lock) }?; + let generation_fence = absolute_or_self(active_generation_fence)?; + let hook_config = absolute_or_self(&layout.hook_config)?; + host.persistent_hook_config(&generation_fence, &generation_token) + .write(&hook_config)?; write_json( &layout.mcp_config, - &plugin_mcp_config(host, relay, active_generation_fence, &generation_token)?, + &plugin_mcp_config(host, relay, &generation_fence, &generation_token)?, )?; write_json( &layout.hooks_path, - &plugin_hooks(host, relay, active_generation_fence, &generation_token)?, + &plugin_hooks(host, relay, &generation_fence, &generation_token)?, )?; Ok(()) } @@ -113,6 +118,6 @@ pub(super) fn plugin_hooks( generation_fence: &Path, generation_token: &str, ) -> Result { - let generation_fence = absolute_or_self(generation_fence)?; - host.plugin_hooks(relay, &generation_fence, generation_token) + let hook_config = generation_fence.with_file_name(".nemo-relay-hook-config.json"); + host.plugin_hooks(relay, generation_fence, generation_token, &hook_config) } diff --git a/crates/cli/src/installation/marketplace/mod.rs b/crates/cli/src/installation/marketplace/mod.rs index c00c5e65f..d76277e05 100644 --- a/crates/cli/src/installation/marketplace/mod.rs +++ b/crates/cli/src/installation/marketplace/mod.rs @@ -1244,9 +1244,11 @@ fn collect_host_plugin_readiness( if let Some(plugin) = readiness.plugin.as_ref() { let generation_fence = plugin.join(crate::installation::generation::GENERATION_FILE_NAME); + let hook_config = plugin.join(".nemo-relay-hook-config.json"); readiness.push( "Generated hooks", InstallGeneration::capture(generation_fence.clone()).and_then(|generation| { + crate::hooks::HookCommandConfig::load(&hook_config)?; let expected = plugin_hooks(host, &relay, &generation_fence, generation.token())?; generated_manifest_check( diff --git a/crates/cli/src/installation/marketplace/spec.rs b/crates/cli/src/installation/marketplace/spec.rs index 8fca35a7e..d1acd02c6 100644 --- a/crates/cli/src/installation/marketplace/spec.rs +++ b/crates/cli/src/installation/marketplace/spec.rs @@ -41,11 +41,17 @@ pub(crate) trait MarketplaceHost: Copy { fn marketplace_manifest(self, marketplace: &str, plugin: &str) -> Value; fn plugin_manifest(self, plugin: &str) -> Value; fn plugin_mcp_config(self, server: Value) -> Result; + fn persistent_hook_config( + self, + generation_fence: &Path, + generation_token: &str, + ) -> crate::hooks::HookCommandConfig; fn plugin_hooks( self, relay: &Path, generation_fence: &Path, generation_token: &str, + hook_config: &Path, ) -> Result; fn plugin_registration_args(self, plugin_id: &str) -> Vec; fn plugin_removal_args(self, plugin_name: &str, plugin_id: &str) -> Vec; diff --git a/crates/cli/src/installation/marketplace/state.rs b/crates/cli/src/installation/marketplace/state.rs index af87e27db..019df30e1 100644 --- a/crates/cli/src/installation/marketplace/state.rs +++ b/crates/cli/src/installation/marketplace/state.rs @@ -46,6 +46,7 @@ pub(super) struct PluginLayout { pub(super) mcp_config: PathBuf, pub(super) generation_fence: PathBuf, pub(super) generation_lock: PathBuf, + pub(super) hook_config: PathBuf, pub(super) hooks_path: PathBuf, pub(super) state_path: PathBuf, } @@ -71,6 +72,7 @@ impl PluginLayout { host.install_arg() )); let hooks_path = plugin_root.join("hooks").join("hooks.json"); + let hook_config = plugin_root.join(".nemo-relay-hook-config.json"); let state_path = state_path(host, install_dir); Self { host_arg: host.install_arg(), @@ -82,6 +84,7 @@ impl PluginLayout { mcp_config, generation_fence, generation_lock, + hook_config, hooks_path, state_path, } diff --git a/crates/cli/src/process/mod.rs b/crates/cli/src/process/mod.rs index 4fb6f7d7c..a341a582d 100644 --- a/crates/cli/src/process/mod.rs +++ b/crates/cli/src/process/mod.rs @@ -51,7 +51,8 @@ fn cmd_quote_arg(raw: &str) -> String { let mut escaped = String::new(); for ch in raw.chars() { match ch { - '%' => escaped.push_str("%%cd:~,%"), + '%' => escaped.push_str("^%"), + '^' => escaped.push_str("^^"), '"' => escaped.push_str("\"\""), _ => escaped.push(ch), } @@ -71,6 +72,41 @@ pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { .unwrap_or(path) } +#[cfg(windows)] +pub(crate) fn short_windows_path(path: &Path) -> Option { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + let source = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: `source` is NUL-terminated and the null output buffer is explicitly supported + // for querying the required output length. + let required = unsafe { + windows_sys::Win32::Storage::FileSystem::GetShortPathNameW( + source.as_ptr(), + std::ptr::null_mut(), + 0, + ) + }; + if required == 0 { + return None; + } + let mut destination = vec![0_u16; required as usize + 1]; + // SAFETY: both buffers are valid for the supplied lengths and `destination` has room for + // the documented terminating NUL. + let written = unsafe { + windows_sys::Win32::Storage::FileSystem::GetShortPathNameW( + source.as_ptr(), + destination.as_mut_ptr(), + destination.len() as u32, + ) + }; + (written > 0 && written <= required) + .then(|| PathBuf::from(OsString::from_wide(&destination[..written as usize]))) +} + #[cfg(not(windows))] pub(crate) fn portable_executable_path(path: PathBuf) -> PathBuf { path diff --git a/crates/cli/tests/coverage/agents/launcher_tests.rs b/crates/cli/tests/coverage/agents/launcher_tests.rs index 4ba9ffce0..de7eaa606 100644 --- a/crates/cli/tests/coverage/agents/launcher_tests.rs +++ b/crates/cli/tests/coverage/agents/launcher_tests.rs @@ -963,9 +963,11 @@ fn prepares_claude_temp_plugin() { &[ "hook-forward", "claude", - "--gateway-url", - "http://127.0.0.1:1234", - "--transparent-run", + "--hook-config", + plugin_dir + .join(".nemo-relay-hook-config.json") + .to_str() + .unwrap(), ], )); assert!( diff --git a/crates/cli/tests/coverage/agents/plugin_host_tests.rs b/crates/cli/tests/coverage/agents/plugin_host_tests.rs index de331e658..9dde06c60 100644 --- a/crates/cli/tests/coverage/agents/plugin_host_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_host_tests.rs @@ -3094,10 +3094,10 @@ fn windows_shell_argument_quoting_and_hook_encoding_preserve_paths() { std::path::PathBuf::from(r"C:\Program Files\NeMo 100%\plugin\.nemo-relay-generation"); assert_eq!( shell_quote_arg_for_platform(relay.to_str().unwrap(), true), - r#""C:\Program Files\NeMo 100%%cd:~,%\bin\nemo-relay.exe""# + r#""C:\Program Files\NeMo 100^%\bin\nemo-relay.exe""# ); assert_eq!( - crate::hooks::decode_windows_hook_command( + crate::hook_assertions::decode_windows_hook_command( codex_plugin_hook_command_for_platform(&relay, &generation, "test-generation", true,) .for_event("PreToolUse") ) @@ -3106,12 +3106,11 @@ fn windows_shell_argument_quoting_and_hook_encoding_preserve_paths() { relay.display().to_string(), "hook-forward".into(), "codex".into(), - "--gateway-url".into(), - DEFAULT_URL.into(), - "--generation-file".into(), - generation.display().to_string(), - "--generation-token".into(), - "test-generation".into(), + "--hook-config".into(), + generation + .with_file_name(".nemo-relay-hook-config.json") + .display() + .to_string(), "--fail-closed".into(), ] ); @@ -3119,12 +3118,18 @@ fn windows_shell_argument_quoting_and_hook_encoding_preserve_paths() { shell_quote_arg_for_platform("foo&bar", true), r#""foo&bar""# ); + assert_eq!( + shell_quote_arg_for_platform("foo^bar", true), + r#""foo^^bar""# + ); assert_eq!(shell_quote_arg_for_platform("", true), r#""""#); } #[cfg(windows)] #[test] fn generated_windows_hook_command_executes_exact_arguments() { + use std::os::windows::process::CommandExt; + let temp = tempfile::tempdir().unwrap(); let bin = temp.path().join("Relay & %USERPROFILE% !^ Tools"); std::fs::create_dir(&bin).unwrap(); @@ -3139,13 +3144,16 @@ fn generated_windows_hook_command_executes_exact_arguments() { .to_owned(); let mut child = std::process::Command::new("cmd.exe") .arg("/C") - .arg(&command) + .raw_arg(format!(" {command}")) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .env("NEMO_RELAY_HOOK_MARKER", &marker) .env("NEMO_RELAY_HOOK_INPUT_MARKER", &input_marker) - .env("NEMO_RELAY_HOOK_GENERATION", &generation) + .env( + "NEMO_RELAY_HOOK_CONFIG", + generation.with_file_name(".nemo-relay-hook-config.json"), + ) .env("NEMO_RELAY_HOOK_EMIT_OUTPUT", "1") .spawn() .unwrap(); @@ -3153,7 +3161,13 @@ fn generated_windows_hook_command_executes_exact_arguments() { child.stdin.take().unwrap().write_all(b"ping\n").unwrap(); let output = child.wait_with_output().unwrap(); - assert!(output.status.success(), "{command}"); + assert!( + output.status.success(), + "command: {command}\nstatus: {:?}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); assert_eq!(std::fs::read_to_string(marker).unwrap().trim(), "ok"); assert_eq!(std::fs::read(input_marker).unwrap(), b"ping\n"); assert_eq!( @@ -3169,6 +3183,8 @@ fn generated_windows_hook_command_executes_exact_arguments() { #[cfg(windows)] #[test] fn generated_windows_hook_command_propagates_the_relay_exit_code() { + use std::os::windows::process::CommandExt; + let temp = tempfile::tempdir().unwrap(); let relay = temp.path().join("relay failure.exe"); compile_windows_hook_test_relay(&relay); @@ -3180,8 +3196,11 @@ fn generated_windows_hook_command_propagates_the_relay_exit_code() { let status = std::process::Command::new("cmd.exe") .arg("/C") - .arg(&command) - .env("NEMO_RELAY_HOOK_GENERATION", &generation) + .raw_arg(format!(" {command}")) + .env( + "NEMO_RELAY_HOOK_CONFIG", + generation.with_file_name(".nemo-relay-hook-config.json"), + ) .env("NEMO_RELAY_HOOK_EXIT_CODE", "23") .status() .unwrap(); @@ -3215,10 +3234,16 @@ fn posix_shell_argument_quoting_and_hook_encoding_preserve_paths() { shell_quote_arg_for_platform(relay.to_str().unwrap(), false), "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay'" ); + let hook_config = generation.with_file_name(".nemo-relay-hook-config.json"); + let expected = format!( + "{} hook-forward codex --hook-config {} --fail-open", + shell_quote_arg_for_platform(relay.to_str().unwrap(), false), + shell_quote_arg_for_platform(hook_config.to_str().unwrap(), false), + ); assert_eq!( codex_plugin_hook_command_for_platform(&relay, &generation, "test-generation", false) .for_event("SessionStart"), - "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:47632 --generation-file '/tmp/NeMo $Relay`test'\\''/plugin/.nemo-relay-generation' --generation-token test-generation --fail-open" + expected ); assert_eq!(shell_quote_arg_for_platform("", false), "''"); assert_eq!( diff --git a/crates/cli/tests/coverage/agents/plugin_install_tests.rs b/crates/cli/tests/coverage/agents/plugin_install_tests.rs index 1aa35fd44..52321cc5c 100644 --- a/crates/cli/tests/coverage/agents/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/agents/plugin_install_tests.rs @@ -2396,10 +2396,8 @@ fn force_install_retires_previous_mcp_generation() { let cached_mcp = serde_json::from_str::(&std::fs::read_to_string(&layout.mcp_config).unwrap()) .unwrap(); - let cached_hooks = serde_json::from_str::( - &std::fs::read_to_string(&layout.hooks_path).unwrap(), - ) - .unwrap(); + let cached_hook_config: Value = + serde_json::from_str(&std::fs::read_to_string(&layout.hook_config).unwrap()).unwrap(); install_host(CodingAgent::Codex, &options, &runner, &setup_runner).unwrap(); @@ -2421,18 +2419,24 @@ fn force_install_retires_previous_mcp_generation() { cached_mcp["nemo-relay"]["env"]["NEMO_RELAY_MCP_GENERATION"], json!(previous_token) ); - assert!(crate::hook_assertions::value_has_command_arguments( - &cached_hooks, - &["--generation-token", &previous_token] - )); + assert_eq!( + cached_hook_config["generation_token"], + json!(previous_token) + ); let current_hooks = serde_json::from_str::( &std::fs::read_to_string(&layout.hooks_path).unwrap(), ) .unwrap(); assert!(crate::hook_assertions::value_has_command_arguments( ¤t_hooks, - &["--generation-token", current.token()] + &["--hook-config", layout.hook_config.to_str().unwrap()] )); + let current_hook_config: Value = + serde_json::from_str(&std::fs::read_to_string(&layout.hook_config).unwrap()).unwrap(); + assert_eq!( + current_hook_config["generation_token"], + json!(current.token()) + ); assert!(layout.generation_lock.exists()); } diff --git a/crates/cli/tests/coverage/shared/hook_assertions.rs b/crates/cli/tests/coverage/shared/hook_assertions.rs index 10a97825e..0376fc7d6 100644 --- a/crates/cli/tests/coverage/shared/hook_assertions.rs +++ b/crates/cli/tests/coverage/shared/hook_assertions.rs @@ -3,9 +3,22 @@ use serde_json::Value; +pub(crate) fn decode_windows_hook_command(command: &str) -> Option> { + let command = command + .strip_prefix('"') + .and_then(|command| command.strip_suffix('"')) + .unwrap_or(command); + shell_words::split(command).ok().map(|arguments| { + arguments + .into_iter() + .map(|argument| argument.replace("^%", "%")) + .collect() + }) +} + pub(crate) fn command_has_arguments(command: &str, expected: &[&str]) -> bool { - let arguments = crate::hooks::decode_windows_hook_command(command) - .or_else(|| shell_words::split(command).ok()); + let arguments = + decode_windows_hook_command(command).or_else(|| shell_words::split(command).ok()); arguments.is_some_and(|arguments| { arguments.windows(expected.len()).any(|window| { window diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs index f7ae88d74..eb5e0a73d 100644 --- a/crates/cli/tests/coverage/shared/installer_tests.rs +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use base64::Engine; use std::path::Path; use std::time::Duration; @@ -11,6 +10,36 @@ use serde_json::Value; use crate::agents::CodingAgent; +#[test] +fn private_hook_config_round_trips_and_rejects_agent_mismatch() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("hook.json"); + HookCommandConfig::transparent(CodingAgent::Codex, "http://127.0.0.1:1234") + .write(&path) + .unwrap(); + + let config = HookCommandConfig::load(&path).unwrap(); + let mut request = HookForwardRequest { + agent: CodingAgent::ClaudeCode, + hook_config: Some(path), + gateway_url: None, + generation_file: None, + generation_token: None, + forward_only: false, + transparent_run: false, + profile: None, + session_metadata: None, + gateway_mode: None, + failure_policy: HookFailurePolicy::Default, + }; + assert!( + config + .apply(&mut request) + .unwrap_err() + .contains("requested claude") + ); +} + struct BootstrapConfigHome { _guard: std::sync::MutexGuard<'static, ()>, previous: Option, @@ -115,6 +144,7 @@ async fn transparent_hook_delivery_authenticates_the_wrapper_gateway() { .expect("wrapper gateway did not become healthy"); let command = HookForwardRequest { agent: CodingAgent::Codex, + hook_config: None, gateway_url: Some(gateway_url.clone()), generation_file: None, generation_token: None, @@ -277,27 +307,6 @@ fn hook_response_statuses_preserve_guardrail_rejections_and_fail_closed_errors() 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::>(), - ); - assert!(decode_windows_hook_command(&format!("{launcher}{SEPARATOR}{encoded}")).is_none()); -} - #[test] fn merge_hooks_is_idempotent_and_preserves_existing_entries() { let existing = json!({ @@ -361,53 +370,33 @@ fn helper_formatting_and_headers_cover_optional_paths() { #[test] fn generated_hook_dispatch_covers_all_agents() { assert_generated_hook_policies(); + let config = "/private/nemo-relay-hook.json"; assert_eq!( transparent_hook_forward_commands_for_platform( Path::new("/abs/path/to/nemo-relay"), CodingAgent::Codex, - "http://127.0.0.1:1234", + config, false, ) .for_event("PreToolUse"), - "/abs/path/to/nemo-relay hook-forward codex --gateway-url http://127.0.0.1:1234 --transparent-run --fail-closed" + "/abs/path/to/nemo-relay hook-forward codex --hook-config /private/nemo-relay-hook.json --fail-closed" ); let relay = Path::new("/opt/NeMo Relay's & tools/nemo-relay"); assert_eq!( - transparent_hook_forward_commands_for_platform( - relay, - CodingAgent::Codex, - "http://127.0.0.1:1234", - false - ) - .for_event("SessionStart"), - r#"'/opt/NeMo Relay'\''s & tools/nemo-relay' hook-forward codex --gateway-url http://127.0.0.1:1234 --transparent-run --fail-open"# + transparent_hook_forward_commands_for_platform(relay, CodingAgent::Codex, config, false) + .for_event("SessionStart"), + r#"'/opt/NeMo Relay'\''s & tools/nemo-relay' hook-forward codex --hook-config /private/nemo-relay-hook.json --fail-open"# ); - let native = transparent_hook_forward_commands( - Path::new("nemo-relay"), - CodingAgent::Codex, - "http://127.0.0.1:1234", - ) - .unwrap(); - if cfg!(windows) { - assert_eq!( - decode_windows_hook_command(native.for_event("on_session_start")).unwrap(), - vec![ - String::from("nemo-relay"), - String::from("hook-forward"), - String::from("codex"), - String::from("--gateway-url"), - String::from("http://127.0.0.1:1234"), - String::from("--transparent-run"), - String::from("--fail-open"), - ] - ); - } else { + let native = + transparent_hook_forward_commands(Path::new("nemo-relay"), CodingAgent::Codex, config) + .unwrap(); + if !cfg!(windows) { assert_eq!( native, transparent_hook_forward_commands_for_platform( Path::new("nemo-relay"), CodingAgent::Codex, - "http://127.0.0.1:1234", + config, false, ) ); @@ -415,56 +404,13 @@ fn generated_hook_dispatch_covers_all_agents() { let windows = transparent_hook_forward_commands_for_platform( relay, CodingAgent::ClaudeCode, - "http://127.0.0.1:1234", + config, true, ); let windows = windows.for_event("PreToolUse"); - 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(), - "--fail-closed".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")); + assert!(windows.contains("--hook-config")); + assert!(!windows.contains("PowerShell")); + assert!(!windows.contains("EncodedCommand")); } fn assert_generated_hook_policies() { diff --git a/crates/cli/tests/fixtures/windows_hook_relay.rs b/crates/cli/tests/fixtures/windows_hook_relay.rs index cedd3c885..b01d383a2 100644 --- a/crates/cli/tests/fixtures/windows_hook_relay.rs +++ b/crates/cli/tests/fixtures/windows_hook_relay.rs @@ -5,17 +5,13 @@ use std::ffi::OsString; use std::io::Read; fn main() { - let generation = std::env::var_os("NEMO_RELAY_HOOK_GENERATION") - .expect("NEMO_RELAY_HOOK_GENERATION is required"); + let hook_config = std::env::var_os("NEMO_RELAY_HOOK_CONFIG") + .expect("NEMO_RELAY_HOOK_CONFIG is required"); let expected = vec![ OsString::from("hook-forward"), OsString::from("codex"), - OsString::from("--gateway-url"), - OsString::from("http://127.0.0.1:47632"), - OsString::from("--generation-file"), - generation, - OsString::from("--generation-token"), - OsString::from("test-generation"), + OsString::from("--hook-config"), + hook_config, OsString::from("--fail-closed"), ]; let actual = std::env::args_os().skip(1).collect::>(); diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 562ce5661..65063b0ba 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -115,13 +115,15 @@ undiscoverable `PostToolUseFailure`, `Notification`, or `SessionEnd` handlers. Upgrade removes legacy Relay groups from `~/.codex/hooks.json` while preserving unrelated hooks. -On Windows, generated hooks use the built-in Windows PowerShell encoded-command -format. This avoids quoting and metacharacter differences between the Codex and -Claude Code command runners. The encoded payload contains only the -canonical `nemo-relay.exe` path and `hook-forward` arguments. PowerShell starts -that Rust binary directly and preserves its standard input, standard output, -standard error, and exit code. The MCP client and gateway remain Rust-native, -and both install and doctor verify the generated command and event ownership. +After upgrading to a release with file-backed hook configuration, uninstall and +reinstall the Relay plugin so the coding-agent host refreshes its stored hook +commands and trust hashes. + +Generated hooks invoke the native Relay binary with a short private +`--hook-config` path. The private Relay-owned file contains the gateway URL, +generation fence, and hook lifecycle settings, so host-managed hook +configuration does not expose those values or exceed Windows command-length +limits. Both install and doctor verify the generated command and event ownership. Start a new Codex CLI process after installation. Restart the Codex desktop app if it was already running so it reloads the provider and hook configuration. @@ -312,8 +314,8 @@ custom automation, use these supported replacements: | `nemo-relay plugin-shim provider claude status` | `nemo-relay doctor --plugin claude-code` | | `nemo-relay plugin-shim doctor ` | `nemo-relay doctor --plugin ` | -Persistent generated hook commands include the fixed gateway URL. Transparent -wrapper hooks embed their dynamic gateway URL, while the process environment +Persistent and transparent generated hook commands reference a private Relay +hook configuration instead of embedding gateway details. The process environment lets an installed plugin MCP authenticate, borrow, and monitor that exact gateway. Transparent hook delivery authenticates the wrapper gateway before writing its lifecycle payload. When a transparent run uses a recognizable