Skip to content
Draft
10 changes: 7 additions & 3 deletions crates/cli/src/agents/claude/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions crates/cli/src/agents/codex/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1789,28 +1789,28 @@ 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::GeneratedHookCommands, String> {
crate::hooks::persistent_hook_forward_commands(
relay,
CodingAgent::Codex,
generation,
generation_token,
_generation_token,
)
}

#[cfg(test)]
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,
)
}
Expand Down
18 changes: 15 additions & 3 deletions crates/cli/src/agents/codex/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -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);
Expand Down Expand Up @@ -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<PathBuf, CliError> {
let path = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&path)?;
Ok(path)
}
16 changes: 15 additions & 1 deletion crates/cli/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value, String> {
let commands = crate::hooks::persistent_hook_forward_commands(
relay,
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions crates/cli/src/commands/hook_forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
/// Base URL of the Relay gateway that receives the lifecycle payload.
#[arg(long)]
pub(crate) gateway_url: Option<String>,
Expand Down Expand Up @@ -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,
Expand Down
135 changes: 135 additions & 0 deletions crates/cli/src/hooks/config.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf>,
generation_token: Option<String>,
forward_only: bool,
transparent_run: bool,
profile: Option<String>,
session_metadata: Option<String>,
gateway_mode: Option<GatewayMode>,
}

impl HookCommandConfig {
pub(crate) fn persistent(
agent: CodingAgent,
gateway_url: impl Into<String>,
generation_file: PathBuf,
generation_token: impl Into<String>,
) -> 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<String>) -> 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<Self, String> {
let bytes = std::fs::read(path).map_err(|error| {
format!(
"failed to read hook configuration {}: {error}",
path.display()
)
})?;
let config = serde_json::from_slice::<Self>(&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(())
}
}
19 changes: 14 additions & 5 deletions crates/cli/src/hooks/delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading