From adafe09275418502f009eabb75c4453f83ecc20a Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Mon, 6 Jul 2026 13:26:45 -0600 Subject: [PATCH 01/10] feat(plugin): add owned dynamic host activation Signed-off-by: Bryan Bednarski --- crates/cli/src/server.rs | 178 ++----- crates/core/src/plugin.rs | 482 +++++++++++++++++- crates/core/src/plugin/dynamic.rs | 2 + crates/core/src/plugin/dynamic/host.rs | 275 ++++++++++ .../tests/integration/native_plugin_tests.rs | 192 ++++++- .../tests/integration/worker_plugin_tests.rs | 44 +- crates/core/tests/unit/plugin_tests.rs | 446 +++++++++++++++- 7 files changed, 1462 insertions(+), 157 deletions(-) create mode 100644 crates/core/src/plugin/dynamic/host.rs diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 386ab8a26..20d8c3c12 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -12,13 +12,8 @@ use axum::extract::{DefaultBodyLimit, State}; use axum::http::HeaderMap; use axum::routing::{get, post}; use axum::{Json, Router}; -use nemo_relay::plugin::dynamic::{ - DynamicPluginKind, NativePluginActivation, NativePluginLoadSpec, WorkerPluginActivation, - WorkerPluginLoadSpec, load_native_plugins, load_worker_plugins, -}; -use nemo_relay::plugin::{ - PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins_exact, -}; +use nemo_relay::plugin::PluginConfig; +use nemo_relay::plugin::dynamic::{DynamicPluginActivationSpec, PluginHostActivation}; use nemo_relay_adaptive::plugin_component::register_adaptive_component; use nemo_relay_pii_redaction::component::register_pii_redaction_component; use reqwest::Client; @@ -152,7 +147,7 @@ async fn serve_listener_with_dynamic_inner( shutdown_mode: Option, ) -> Result<(), CliError> { let plugin_activation = - PluginActivation::initialize(config.plugin_config.clone(), dynamic_plugins).await?; + initialize_plugin_host(config.plugin_config.clone(), dynamic_plugins).await?; let state = AppState::new(config); let sessions = state.sessions.clone(); let last_activity = state.last_activity.clone(); @@ -187,7 +182,13 @@ async fn serve_listener_with_dynamic_inner( }; let close_result = sessions.close_all("gateway_shutdown").await; let flush_result = nemo_relay::api::runtime::flush_subscribers().map_err(CliError::from); - let clear_result = plugin_activation.clear(); + let clear_result = plugin_activation + .map(|activation| { + activation + .clear() + .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}"))) + }) + .unwrap_or(Ok(())); if let Err(serve_error) = serve_result { if let Err(close_error) = close_result { eprintln!("session teardown failed after server error: {close_error}"); @@ -317,129 +318,46 @@ async fn idle_shutdown_future( } } -struct PluginActivation { - active: bool, - native: Option, - worker: Option, -} - -impl PluginActivation { - async fn initialize( - config: Option, - dynamic_plugins: Vec, - ) -> Result { - if config.is_none() && dynamic_plugins.is_empty() { - return Ok(Self { - active: false, - native: None, - worker: None, - }); - }; - register_adaptive_component().map_err(|error| { - CliError::Config(format!("adaptive plugin registration failed: {error}")) - })?; - register_pii_redaction_component().map_err(|error| { - CliError::Config(format!("PII redaction plugin registration failed: {error}")) - })?; - let native_specs = dynamic_plugins - .iter() - .filter(|plugin| plugin.kind == DynamicPluginKind::RustDynamic) - .map(|plugin| { - let manifest_ref = plugin.manifest_ref.clone().ok_or_else(|| { - CliError::Config(format!( - "native dynamic plugin '{}' has no manifest_ref in lifecycle state", - plugin.plugin_id - )) - })?; - Ok(NativePluginLoadSpec { - plugin_id: plugin.plugin_id.clone(), - manifest_ref, - }) - }) - .collect::, CliError>>()?; - let worker_specs = dynamic_plugins - .iter() - .filter(|plugin| plugin.kind == DynamicPluginKind::Worker) - .map(|plugin| { - let manifest_ref = plugin.manifest_ref.clone().ok_or_else(|| { - CliError::Config(format!( - "worker dynamic plugin '{}' has no manifest_ref in lifecycle state", - plugin.plugin_id - )) - })?; - Ok(WorkerPluginLoadSpec { - plugin_id: plugin.plugin_id.clone(), - manifest_ref, - environment_ref: plugin.environment_ref.clone(), - config: plugin.config.clone(), - }) +async fn initialize_plugin_host( + config: Option, + dynamic_plugins: Vec, +) -> Result, CliError> { + if config.is_none() && dynamic_plugins.is_empty() { + return Ok(None); + } + register_adaptive_component().map_err(|error| { + CliError::Config(format!("adaptive plugin registration failed: {error}")) + })?; + register_pii_redaction_component().map_err(|error| { + CliError::Config(format!("PII redaction plugin registration failed: {error}")) + })?; + let plugin_config: PluginConfig = match config { + Some(config) => serde_json::from_value(config) + .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?, + None => PluginConfig::default(), + }; + let specs = dynamic_plugins + .into_iter() + .map(|plugin| { + let manifest_ref = plugin.manifest_ref.ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin '{}' has no manifest_ref in lifecycle state", + plugin.plugin_id + )) + })?; + Ok(DynamicPluginActivationSpec { + plugin_id: plugin.plugin_id, + kind: plugin.kind, + manifest_ref, + environment_ref: plugin.environment_ref, + config: plugin.config, }) - .collect::, CliError>>()?; - let native = - if native_specs.is_empty() { - None - } else { - Some(load_native_plugins(native_specs).map_err(|error| { - CliError::Config(format!("native plugin load failed: {error}")) - })?) - }; - let worker = - if worker_specs.is_empty() { - None - } else { - Some(load_worker_plugins(worker_specs).map_err(|error| { - CliError::Config(format!("worker plugin load failed: {error}")) - })?) - }; - // Gateway already resolved its config; activate exactly (no re-discovery). - let mut plugin_config: PluginConfig = match config { - Some(config) => serde_json::from_value(config) - .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?, - None => PluginConfig::default(), - }; - plugin_config - .components - .extend( - dynamic_plugins - .into_iter() - .map(|plugin| PluginComponentSpec { - kind: plugin.plugin_id, - enabled: true, - config: plugin.config, - }), - ); - initialize_plugins_exact(plugin_config) - .await - .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; - Ok(Self { - active: true, - native, - worker, }) - } - - fn clear(mut self) -> Result<(), CliError> { - let result = if self.active { - self.active = false; - clear_plugin_configuration() - .map_err(|error| CliError::Config(format!("plugin teardown failed: {error}")))?; - Ok(()) - } else { - Ok(()) - }; - self.native.take(); - self.worker.take(); - result - } -} - -impl Drop for PluginActivation { - fn drop(&mut self) { - if self.active { - let _ = clear_plugin_configuration(); - self.active = false; - } - } + .collect::, CliError>>()?; + let (activation, _) = PluginHostActivation::activate(plugin_config, specs) + .await + .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; + Ok(Some(activation)) } // Normalizes a Codex hook payload, applies all resulting events before responding, and returns the diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 196ddfb20..12708a2af 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -9,10 +9,13 @@ //! - plugin registration contexts for middleware/subscriber installation //! - rollback bookkeeping for registrations created during plugin setup +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::fmt; use std::future::Future; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, LazyLock, Mutex, OnceLock, RwLock}; use serde::{Deserialize, Serialize}; @@ -48,7 +51,25 @@ type PluginMap = HashMap>; static PLUGIN_HANDLERS: LazyLock> = LazyLock::new(|| RwLock::new(HashMap::new())); static ACTIVE_PLUGIN_CONFIGURATION: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +static PLUGIN_MUTATION_OWNER: LazyLock> = + LazyLock::new(|| Mutex::new(PluginMutationOwner::Idle)); +static NEXT_PLUGIN_HOST_OWNER_ID: AtomicU64 = AtomicU64::new(1); static BUILTIN_PLUGIN_REGISTRATION: OnceLock> = OnceLock::new(); +static PLUGIN_MUTATION_EXECUTOR: OnceLock = OnceLock::new(); + +type PluginMutationJob = Pin + Send + 'static>>; +type PluginMutationSender = tokio::sync::mpsc::UnboundedSender; + +thread_local! { + static IN_PLUGIN_MUTATION_EXECUTOR: Cell = const { Cell::new(false) }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PluginMutationOwner { + Idle, + Legacy, + Host(u64), +} /// Error type for generic plugin operations. #[derive(Debug, Error)] @@ -1020,6 +1041,137 @@ pub fn plugin_config_schema() -> Json { /// is removed before the new configuration is activated. #[doc(hidden)] pub async fn initialize_plugins_exact(config: PluginConfig) -> Result { + run_owned_plugin_mutation("plugin initialization", move || async move { + let lease = LegacyPluginMutationLease::acquire()?; + let rollback_failures = Arc::new(Mutex::new(Vec::new())); + let initialization = tokio::spawn(initialize_plugins_exact_inner( + config, + Some(Arc::clone(&rollback_failures)), + )) + .await + .map_err(|error| { + PluginError::Internal(format!("plugin initialization task failed: {error}")) + }); + let result = initialization.and_then(|result| result); + let failures = rollback_failures + .lock() + .map(|failures| failures.clone()) + .unwrap_or_else(|lock_error| { + vec![format!("rollback failure lock poisoned: {lock_error}")] + }); + match result { + Err(error) if !failures.is_empty() => { + std::mem::forget(lease); + Err(PluginError::RegistrationFailed(format!( + concat!( + "{}; initialization rollback was incomplete: {}; plugin ", + "configuration mutations are disabled for this process because callbacks ", + "may remain registered" + ), + error, + failures.join("; ") + ))) + } + result => result, + } + }) + .await +} + +pub(crate) async fn run_owned_plugin_mutation( + operation_name: &'static str, + operation: F, +) -> Result +where + T: Send + 'static, + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + if IN_PLUGIN_MUTATION_EXECUTOR.get() { + return operation().await; + } + + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + plugin_mutation_executor()? + .send(Box::pin(async move { + let result = tokio::spawn(operation()) + .await + .map_err(|error| { + PluginError::Internal(format!("{operation_name} task failed: {error}")) + }) + .and_then(|result| result); + let _ = result_tx.send(result); + })) + .map_err(|_| { + PluginError::Internal(format!( + "failed to queue {operation_name}: executor stopped" + )) + })?; + result_rx.await.map_err(|_| { + PluginError::Internal(format!( + "{operation_name} task stopped before returning a result" + )) + })? +} + +fn plugin_mutation_executor() -> Result<&'static PluginMutationSender> { + if let Some(sender) = PLUGIN_MUTATION_EXECUTOR.get() { + return Ok(sender); + } + + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::(); + let (startup_tx, startup_rx) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("nemo-relay-plugin-host".into()) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = startup_tx.send(Err(error.to_string())); + return; + } + }; + let _ = startup_tx.send(Ok(())); + IN_PLUGIN_MUTATION_EXECUTOR.set(true); + runtime.block_on(async move { + while let Some(job) = receiver.recv().await { + job.await; + } + }); + }) + .map_err(|error| { + PluginError::Internal(format!("failed to start plugin host executor: {error}")) + })?; + startup_rx + .recv() + .map_err(|error| { + PluginError::Internal(format!( + "plugin host executor stopped during startup: {error}" + )) + })? + .map_err(|error| { + PluginError::Internal(format!("failed to start plugin host runtime: {error}")) + })?; + + Ok(PLUGIN_MUTATION_EXECUTOR.get_or_init(|| sender)) +} + +pub(crate) async fn initialize_plugins_exact_for_host( + config: PluginConfig, + owner_id: u64, + rollback_failures: Arc>>, +) -> Result { + verify_plugin_host_owner(owner_id)?; + initialize_plugins_exact_inner(config, Some(rollback_failures)).await +} + +async fn initialize_plugins_exact_inner( + config: PluginConfig, + rollback_failures: Option>>>, +) -> Result { let report = validate_plugin_config(&config); if report.has_errors() { return Err(PluginError::InvalidConfig(join_error_messages(&report))); @@ -1033,13 +1185,30 @@ pub async fn initialize_plugins_exact(config: PluginConfig) -> Result { store_active_plugin_configuration(config, report.clone(), registrations)?; Ok(report) } - Err(err) => match initialize_plugin_components(&previous_state.config).await { + Err(err) => match initialize_plugin_components_catching_panics( + previous_state.config.clone(), + rollback_failures.clone(), + ) + .await + { Ok(registrations) => { let previous_report = validate_plugin_config(&previous_state.config); store_active_plugin_configuration( @@ -1055,12 +1224,26 @@ pub async fn initialize_plugins_exact(config: PluginConfig) -> Result>>>, +) -> Result> { + tokio::spawn(async move { initialize_plugin_components(&config, rollback_failures).await }) + .await + .map_err(|error| { + PluginError::Internal(format!( + "plugin component initialization task failed: {error}" + )) + })? +} + /// Validates and activates `config` layered on top of the discovered /// `plugins.toml` configuration, so a direct integration sees the same file /// layering as the gateway. `config` wins on conflicts; as a typed document its @@ -1208,22 +1391,178 @@ pub fn user_config_dir() -> Option { /// Clearing active configuration does not remove plugin kinds from the global /// registry. pub fn clear_plugin_configuration() -> Result<()> { + let lease = LegacyPluginMutationLease::acquire()?; + let outcome = clear_plugin_configuration_inner(); + if !outcome.callbacks_cleared { + // Deregistration callbacks are single-use. If one failed, the process + // can no longer prove that replacing configuration is safe. + std::mem::forget(lease); + return Err(PluginError::RegistrationFailed(format!( + concat!( + "{}; plugin configuration mutations are disabled for this process because ", + "callbacks may remain registered" + ), + outcome + .result + .err() + .map(|error| error.to_string()) + .unwrap_or_else(|| "plugin teardown was incomplete".into()) + ))); + } + outcome.result +} + +pub(crate) fn clear_plugin_configuration_for_host(owner_id: u64) -> PluginHostClearOutcome { + if let Err(error) = verify_plugin_host_owner(owner_id) { + return PluginHostClearOutcome { + result: Err(error), + callbacks_cleared: false, + }; + } + clear_plugin_configuration_inner() +} + +pub(crate) struct PluginHostClearOutcome { + pub(crate) result: Result<()>, + pub(crate) callbacks_cleared: bool, +} + +fn clear_plugin_configuration_inner() -> PluginHostClearOutcome { let flush_error = crate::api::runtime::flush_subscribers() .err() .map(|error| error.to_string()); let previous = { - let mut guard = ACTIVE_PLUGIN_CONFIGURATION.lock().map_err(|err| { - PluginError::Internal(format!("active plugin configuration lock poisoned: {err}")) - })?; + let mut guard = match ACTIVE_PLUGIN_CONFIGURATION.lock() { + Ok(guard) => guard, + Err(err) => { + return PluginHostClearOutcome { + result: Err(PluginError::Internal(format!( + "active plugin configuration lock poisoned: {err}" + ))), + callbacks_cleared: false, + }; + } + }; guard.take() }; - if let Some(mut previous_state) = previous { - rollback_registrations(&mut previous_state.registrations); + let deregistration_errors = previous + .map(|mut previous_state| rollback_registrations_checked(&mut previous_state.registrations)) + .unwrap_or_default(); + let callbacks_cleared = deregistration_errors.is_empty(); + let deregistration_error = (!callbacks_cleared).then(|| { + PluginError::RegistrationFailed(format!( + "plugin teardown failed: {}", + deregistration_errors.join("; ") + )) + }); + let result = match (flush_error, deregistration_error) { + (None, None) => Ok(()), + (Some(flush), None) => Err(PluginError::Internal(flush)), + (None, Some(deregister)) => Err(deregister), + (Some(flush), Some(deregister)) => Err(PluginError::RegistrationFailed(format!( + "{deregister}; subscriber flush also failed: {flush}" + ))), + }; + PluginHostClearOutcome { + result, + callbacks_cleared, } - if let Some(message) = flush_error { - return Err(PluginError::Internal(message)); +} + +pub(crate) fn plugin_configuration_is_active() -> Result { + ACTIVE_PLUGIN_CONFIGURATION + .lock() + .map(|guard| guard.is_some()) + .map_err(|err| { + PluginError::Internal(format!("active plugin configuration lock poisoned: {err}")) + }) +} + +pub(crate) struct PluginHostLease { + owner_id: u64, +} + +impl PluginHostLease { + pub(crate) fn owner_id(&self) -> u64 { + self.owner_id } - Ok(()) +} + +impl Drop for PluginHostLease { + fn drop(&mut self) { + if let Ok(mut owner) = PLUGIN_MUTATION_OWNER.lock() + && *owner == PluginMutationOwner::Host(self.owner_id) + { + *owner = PluginMutationOwner::Idle; + } + } +} + +pub(crate) fn acquire_plugin_host_lease() -> Result { + let mut owner = PLUGIN_MUTATION_OWNER.lock().map_err(|err| { + PluginError::Internal(format!("plugin mutation owner lock poisoned: {err}")) + })?; + if *owner != PluginMutationOwner::Idle { + return Err(plugin_mutation_conflict(*owner)); + } + if plugin_configuration_is_active()? { + return Err(PluginError::Conflict( + "plugin configuration is already active; clear it before activating dynamic plugins" + .into(), + )); + } + let owner_id = NEXT_PLUGIN_HOST_OWNER_ID.fetch_add(1, Ordering::Relaxed); + *owner = PluginMutationOwner::Host(owner_id); + Ok(PluginHostLease { owner_id }) +} + +fn verify_plugin_host_owner(owner_id: u64) -> Result<()> { + let owner = PLUGIN_MUTATION_OWNER.lock().map_err(|err| { + PluginError::Internal(format!("plugin mutation owner lock poisoned: {err}")) + })?; + if *owner == PluginMutationOwner::Host(owner_id) { + Ok(()) + } else { + Err(PluginError::Conflict( + "dynamic plugin host no longer owns plugin configuration".into(), + )) + } +} + +struct LegacyPluginMutationLease; + +impl LegacyPluginMutationLease { + fn acquire() -> Result { + let mut owner = PLUGIN_MUTATION_OWNER.lock().map_err(|err| { + PluginError::Internal(format!("plugin mutation owner lock poisoned: {err}")) + })?; + if *owner != PluginMutationOwner::Idle { + return Err(plugin_mutation_conflict(*owner)); + } + *owner = PluginMutationOwner::Legacy; + Ok(Self) + } +} + +impl Drop for LegacyPluginMutationLease { + fn drop(&mut self) { + if let Ok(mut owner) = PLUGIN_MUTATION_OWNER.lock() + && *owner == PluginMutationOwner::Legacy + { + *owner = PluginMutationOwner::Idle; + } + } +} + +fn plugin_mutation_conflict(owner: PluginMutationOwner) -> PluginError { + let message = match owner { + PluginMutationOwner::Idle => "plugin configuration is available", + PluginMutationOwner::Legacy => "another plugin configuration mutation is in progress", + PluginMutationOwner::Host(_) => { + "plugin configuration is owned by an active dynamic plugin host" + } + }; + PluginError::Conflict(message.into()) } /// Returns the last successfully configured plugin report. @@ -1249,10 +1588,37 @@ pub fn active_plugin_report() -> Option { /// This is used internally during failed initialization and by /// [`clear_plugin_configuration`]. pub fn rollback_registrations(registrations: &mut Vec) { + let _ = rollback_registrations_checked(registrations); +} + +fn rollback_registrations_checked(registrations: &mut Vec) -> Vec { + let mut errors = Vec::new(); for registration in registrations.iter_mut().rev() { - let _ = (registration.deregister)(); + let failure = match catch_unwind(AssertUnwindSafe(|| (registration.deregister)())) { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error.to_string()), + Err(payload) => Some(format!( + "deregistration panicked: {}", + panic_payload_message(payload) + )), + }; + if let Some(error) = failure { + errors.push(format!( + "{} registration '{}' could not be removed: {error}", + registration.kind, registration.name + )); + } } registrations.clear(); + errors +} + +fn panic_payload_message(payload: Box) -> String { + payload + .downcast_ref::<&str>() + .map(|message| (*message).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic payload".into()) } struct ActivePluginConfiguration { @@ -1261,11 +1627,14 @@ struct ActivePluginConfiguration { registrations: Vec, } -async fn initialize_plugin_components(config: &PluginConfig) -> Result> { +async fn initialize_plugin_components( + config: &PluginConfig, + rollback_failures: Option>>>, +) -> Result> { ensure_builtin_plugins_registered()?; let totals = plugin_component_totals(config); let mut ordinals: HashMap<&str, usize> = HashMap::new(); - let mut registrations = vec![]; + let mut registrations = PendingPluginRegistrations::new(rollback_failures.clone()); for component in config .components @@ -1273,7 +1642,6 @@ async fn initialize_plugin_components(config: &PluginConfig) -> Result Result, + rollback_failures: Option>>>, +} + +impl PendingPluginRegistrations { + fn new(rollback_failures: Option>>>) -> Self { + Self { + registrations: Vec::new(), + rollback_failures, + } + } + + fn extend(&mut self, registrations: Vec) { + self.registrations.extend(registrations); + } + + fn take(&mut self) -> Vec { + std::mem::take(&mut self.registrations) + } +} + +impl Drop for PendingPluginRegistrations { + fn drop(&mut self) { + let errors = rollback_registrations_checked(&mut self.registrations); + record_rollback_failures(self.rollback_failures.as_ref(), errors); + } +} + +struct PendingPluginRegistrationContext { + context: PluginRegistrationContext, + rollback_failures: Option>>>, +} + +impl PendingPluginRegistrationContext { + fn new(namespace: String, rollback_failures: Option>>>) -> Self { + Self { + context: PluginRegistrationContext::with_namespace(namespace), + rollback_failures, } - registrations.extend(ctx.into_registrations()); } - Ok(registrations) + fn take(&mut self) -> Vec { + std::mem::take(&mut self.context.registrations) + } +} + +impl Drop for PendingPluginRegistrationContext { + fn drop(&mut self) { + let errors = rollback_registrations_checked(&mut self.context.registrations); + record_rollback_failures(self.rollback_failures.as_ref(), errors); + } +} + +fn record_rollback_failures( + rollback_failures: Option<&Arc>>>, + errors: Vec, +) { + if errors.is_empty() { + return; + } + if let Some(rollback_failures) = rollback_failures + && let Ok(mut recorded) = rollback_failures.lock() + { + recorded.extend(errors); + } } fn store_active_plugin_configuration( diff --git a/crates/core/src/plugin/dynamic.rs b/crates/core/src/plugin/dynamic.rs index 983f95bd1..efe26536b 100644 --- a/crates/core/src/plugin/dynamic.rs +++ b/crates/core/src/plugin/dynamic.rs @@ -18,12 +18,14 @@ pub type DynamicPluginId = String; /// Canonical filename for authored Relay plugin manifests. pub const DYNAMIC_PLUGIN_MANIFEST_FILENAME: &str = "relay-plugin.toml"; +mod host; mod manifest; mod native; mod registry; #[cfg(feature = "worker-grpc")] mod worker; +pub use host::*; pub use manifest::*; pub use native::*; pub use registry::*; diff --git a/crates/core/src/plugin/dynamic/host.rs b/crates/core/src/plugin/dynamic/host.rs new file mode 100644 index 000000000..3244ea632 --- /dev/null +++ b/crates/core/src/plugin/dynamic/host.rs @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Owned activation lifecycle for dynamically loaded plugin components. + +use std::sync::{Arc, Mutex}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value as Json}; + +use crate::plugin::{ + ConfigReport, PluginComponentSpec, PluginConfig, PluginHostLease, Result, + acquire_plugin_host_lease, clear_plugin_configuration_for_host, + initialize_plugins_exact_for_host, run_owned_plugin_mutation, +}; + +use super::{DynamicPluginKind, NativePluginActivation, NativePluginLoadSpec, load_native_plugins}; + +#[cfg(feature = "worker-grpc")] +use super::{WorkerPluginActivation, WorkerPluginLoadSpec, load_worker_plugins}; + +/// One dynamic plugin component to load and activate in an embedding host. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginActivationSpec { + /// Expected plugin identifier from the authored manifest. + pub plugin_id: String, + /// Plugin execution lane. + pub kind: DynamicPluginKind, + /// Path or reference to the authored `relay-plugin.toml`. + pub manifest_ref: String, + /// Relay-managed runtime environment used by Python workers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_ref: Option, + /// Component-local configuration passed to the dynamically loaded plugin. + #[serde(default)] + pub config: Map, +} + +/// Owns one process-wide dynamic plugin configuration and its loaded runtimes. +/// +/// The activation keeps native libraries and worker processes alive until after +/// all callbacks and subscribers registered from them have been removed. Only +/// one activation may exist in a process at a time. +#[must_use = "dropping the activation clears and unloads its dynamic plugins"] +pub struct PluginHostActivation { + active: bool, + native: Option, + #[cfg(feature = "worker-grpc")] + worker: Option, + claim: Option, +} + +impl PluginHostActivation { + /// Load dynamic plugins and activate them with `config` as one transaction. + /// + /// Dynamic components are appended to the supplied base configuration in + /// specification order. The returned activation must remain alive for as + /// long as code may invoke plugin-provided callbacks. + pub async fn activate( + config: PluginConfig, + dynamic_plugins: I, + ) -> Result<(Self, ConfigReport)> + where + I: IntoIterator, + { + let dynamic_plugins = dynamic_plugins.into_iter().collect::>(); + run_owned_plugin_mutation("dynamic plugin activation", move || async move { + Self::activate_inner(config, dynamic_plugins).await + }) + .await + } + + async fn activate_inner( + mut config: PluginConfig, + dynamic_plugins: Vec, + ) -> Result<(Self, ConfigReport)> { + let claim = acquire_plugin_host_lease()?; + + #[cfg(not(feature = "worker-grpc"))] + if let Some(plugin) = dynamic_plugins + .iter() + .find(|plugin| plugin.kind == DynamicPluginKind::Worker) + { + return Err(crate::plugin::PluginError::InvalidConfig(format!( + "worker dynamic plugin '{}' requires the 'worker-grpc' feature", + plugin.plugin_id + ))); + } + + let native_specs = dynamic_plugins + .iter() + .filter(|plugin| plugin.kind == DynamicPluginKind::RustDynamic) + .map(|plugin| NativePluginLoadSpec { + plugin_id: plugin.plugin_id.clone(), + manifest_ref: plugin.manifest_ref.clone(), + }) + .collect::>(); + let native = (!native_specs.is_empty()) + .then(|| { + load_native_plugins(native_specs) + .map_err(|error| plugin_error_context("native plugin load failed", error)) + }) + .transpose()?; + + #[cfg(feature = "worker-grpc")] + let worker = { + let worker_specs = dynamic_plugins + .iter() + .filter(|plugin| plugin.kind == DynamicPluginKind::Worker) + .map(|plugin| WorkerPluginLoadSpec { + plugin_id: plugin.plugin_id.clone(), + manifest_ref: plugin.manifest_ref.clone(), + environment_ref: plugin.environment_ref.clone(), + config: plugin.config.clone(), + }) + .collect::>(); + (!worker_specs.is_empty()) + .then(|| { + load_worker_plugins(worker_specs) + .map_err(|error| plugin_error_context("worker plugin load failed", error)) + }) + .transpose()? + }; + + config.components.extend( + dynamic_plugins + .into_iter() + .map(|plugin| PluginComponentSpec { + kind: plugin.plugin_id, + enabled: true, + config: plugin.config, + }), + ); + let rollback_failures = Arc::new(Mutex::new(Vec::new())); + let owner_id = claim.owner_id(); + let initialization = tokio::spawn(initialize_plugins_exact_for_host( + config, + owner_id, + Arc::clone(&rollback_failures), + )) + .await + .map_err(|error| { + crate::plugin::PluginError::Internal(format!( + "dynamic plugin initialization task failed: {error}" + )) + }); + let report = match initialization.and_then(|result| result) { + Ok(report) => report, + Err(error) => { + let failures = rollback_failures + .lock() + .map(|failures| failures.clone()) + .unwrap_or_else(|lock_error| { + vec![format!("rollback failure lock poisoned: {lock_error}")] + }); + if failures.is_empty() { + return Err(error); + } + if let Some(native) = native { + std::mem::forget(native); + } + #[cfg(feature = "worker-grpc")] + if let Some(worker) = worker { + std::mem::forget(worker); + } + std::mem::forget(claim); + return Err(crate::plugin::PluginError::RegistrationFailed(format!( + concat!( + "{}; activation rollback was incomplete: {}; the loaded runtimes ", + "were retained because callbacks may remain registered" + ), + error, + failures.join("; ") + ))); + } + }; + + Ok(( + Self { + active: true, + native, + #[cfg(feature = "worker-grpc")] + worker, + claim: Some(claim), + }, + report, + )) + } + + /// Returns whether this activation still owns an active plugin host. + pub fn is_active(&self) -> bool { + self.active + } + + /// Clear registered callbacks before unloading libraries and workers. + pub fn clear(mut self) -> Result<()> { + self.clear_inner() + } + + fn clear_inner(&mut self) -> Result<()> { + if !self.active { + return Ok(()); + } + self.active = false; + let outcome = self + .claim + .as_ref() + .map(|claim| clear_plugin_configuration_for_host(claim.owner_id())) + .unwrap_or(crate::plugin::PluginHostClearOutcome { + result: Ok(()), + callbacks_cleared: true, + }); + if outcome.callbacks_cleared { + self.native.take(); + #[cfg(feature = "worker-grpc")] + self.worker.take(); + self.claim.take(); + } else { + // If core could not prove callbacks were removed, intentionally + // retain their code and owner for process lifetime rather than + // unload a library or worker that may still be referenced. + if let Some(native) = self.native.take() { + std::mem::forget(native); + } + #[cfg(feature = "worker-grpc")] + if let Some(worker) = self.worker.take() { + std::mem::forget(worker); + } + if let Some(claim) = self.claim.take() { + std::mem::forget(claim); + } + } + if outcome.callbacks_cleared { + outcome.result + } else { + Err(crate::plugin::PluginError::RegistrationFailed(format!( + "{}; the loaded runtimes were retained because callbacks may remain registered", + outcome + .result + .err() + .map(|error| error.to_string()) + .unwrap_or_else(|| "plugin teardown was incomplete".into()) + ))) + } + } +} + +fn plugin_error_context( + prefix: &str, + error: crate::plugin::PluginError, +) -> crate::plugin::PluginError { + use crate::plugin::PluginError; + + match error { + PluginError::InvalidConfig(message) => { + PluginError::InvalidConfig(format!("{prefix}: {message}")) + } + PluginError::Conflict(message) => PluginError::Conflict(format!("{prefix}: {message}")), + PluginError::NotFound(message) => PluginError::NotFound(format!("{prefix}: {message}")), + PluginError::Serialization(error) => { + PluginError::Internal(format!("{prefix}: serialization error: {error}")) + } + PluginError::Internal(message) => PluginError::Internal(format!("{prefix}: {message}")), + PluginError::RegistrationFailed(message) => { + PluginError::RegistrationFailed(format!("{prefix}: {message}")) + } + } +} + +impl Drop for PluginHostActivation { + fn drop(&mut self) { + let _ = self.clear_inner(); + } +} diff --git a/crates/core/tests/integration/native_plugin_tests.rs b/crates/core/tests/integration/native_plugin_tests.rs index 85f6a4b69..e6e8e70ba 100644 --- a/crates/core/tests/integration/native_plugin_tests.rs +++ b/crates/core/tests/integration/native_plugin_tests.rs @@ -23,9 +23,13 @@ use nemo_relay::api::scope::{ use nemo_relay::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber}; use nemo_relay::api::tool::{ToolCallExecuteParams, tool_call_execute, tool_request_intercepts}; use nemo_relay::codec::response::AnnotatedLlmResponse; -use nemo_relay::plugin::dynamic::{NativePluginLoadSpec, load_native_plugins}; +use nemo_relay::plugin::dynamic::{ + DynamicPluginActivationSpec, DynamicPluginKind, NativePluginLoadSpec, PluginHostActivation, + load_native_plugins, +}; use nemo_relay::plugin::{ PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins_exact, + list_plugin_kinds, }; use serde_json::{Map, Value as Json, json}; use sha2::{Digest, Sha256}; @@ -964,6 +968,182 @@ async fn native_validate_and_register_callback_errors_are_reported() { } } +#[tokio::test] +async fn plugin_host_activation_owns_configuration_until_clear() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let (activation, report) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("plugin host should activate"); + + assert!(activation.is_active()); + assert!(!report.has_errors()); + assert!( + list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_native") + ); + let rewritten = tool_request_intercepts("host-owned-tool", json!({ "input": true })) + .expect("host-owned intercept should run"); + assert_eq!(rewritten["native_plugin"], true); + + let initialize_error = initialize_plugins_exact(PluginConfig::default()) + .await + .expect_err("legacy initialize must not replace a host activation") + .to_string(); + assert!(initialize_error.contains("active dynamic plugin host")); + let clear_error = clear_plugin_configuration() + .expect_err("legacy clear must not clear a host activation") + .to_string(); + assert!(clear_error.contains("active dynamic plugin host")); + + activation.clear().expect("plugin host should clear"); + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_native") + ); + let unchanged = tool_request_intercepts("host-owned-tool", json!({ "input": true })) + .expect("cleared intercept chain should be empty"); + assert_eq!(unchanged, json!({ "input": true })); +} + +#[tokio::test] +async fn plugin_host_activation_drop_releases_owner_and_plugin_kind() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + + { + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("first plugin host should activate"); + assert!(activation.is_active()); + } + + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_native") + ); + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("owner should be reusable after drop"); + activation.clear().expect("second plugin host should clear"); +} + +#[tokio::test] +async fn plugin_host_partial_load_failure_rolls_back_and_releases_owner() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let missing_manifest = manifest_ref.with_file_name("missing-relay-plugin.toml"); + + let error = match PluginHostActivation::activate( + PluginConfig::default(), + [ + host_spec("fixture_native", &manifest_ref), + host_spec("missing_native", &missing_manifest), + ], + ) + .await + { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected activation should clear"); + panic!("partial load should fail"); + } + Err(error) => error.to_string(), + }; + assert!(error.contains("missing-relay-plugin.toml"), "{error}"); + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_native") + ); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("failed activation must release the owner"); + activation + .clear() + .expect("recovered activation should clear"); +} + +#[tokio::test] +async fn plugin_host_rejects_an_existing_legacy_configuration() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + initialize_plugins_exact(PluginConfig::default()) + .await + .expect("legacy configuration should initialize"); + + let error = match PluginHostActivation::activate( + PluginConfig::default(), + Vec::::new(), + ) + .await + { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected activation should clear"); + panic!("host activation should reject an existing configuration"); + } + Err(error) => error.to_string(), + }; + assert!(error.contains("already active"), "{error}"); + clear_plugin_configuration().expect("legacy configuration should clear"); +} + +#[cfg(not(feature = "worker-grpc"))] +#[tokio::test] +async fn plugin_host_rejects_workers_when_worker_support_is_disabled() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let error = match PluginHostActivation::activate( + PluginConfig::default(), + [DynamicPluginActivationSpec { + plugin_id: "fixture_worker".into(), + kind: DynamicPluginKind::Worker, + manifest_ref: "unused-worker-manifest.toml".into(), + environment_ref: None, + config: Map::new(), + }], + ) + .await + { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected activation should clear"); + panic!("worker activation should require worker support"); + } + Err(error) => error.to_string(), + }; + assert!(error.contains("worker-grpc"), "{error}"); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + Vec::::new(), + ) + .await + .expect("failed worker activation must release the owner"); + activation.clear().expect("empty host should clear"); +} + async fn initialize_fixture_native(config: Map) -> nemo_relay::plugin::Result<()> { let mut plugin_config = PluginConfig::default(); plugin_config.components.push(PluginComponentSpec { @@ -998,6 +1178,16 @@ fn load_spec(plugin_id: &str, manifest_ref: &Path) -> NativePluginLoadSpec { } } +fn host_spec(plugin_id: &str, manifest_ref: &Path) -> DynamicPluginActivationSpec { + DynamicPluginActivationSpec { + plugin_id: plugin_id.into(), + kind: DynamicPluginKind::RustDynamic, + manifest_ref: manifest_ref.to_string_lossy().into_owned(), + environment_ref: None, + config: Map::new(), + } +} + fn assert_parent( events: &[Event], name: &str, diff --git a/crates/core/tests/integration/worker_plugin_tests.rs b/crates/core/tests/integration/worker_plugin_tests.rs index 842b6c191..2efc55111 100644 --- a/crates/core/tests/integration/worker_plugin_tests.rs +++ b/crates/core/tests/integration/worker_plugin_tests.rs @@ -21,10 +21,12 @@ use nemo_relay::codec::request::AnnotatedLlmRequest; use nemo_relay::codec::traits::LlmCodec; use nemo_relay::error::Result as FlowResult; use nemo_relay::plugin::dynamic::{ - WorkerPluginActivation, WorkerPluginLoadSpec, load_worker_plugins, + DynamicPluginActivationSpec, DynamicPluginKind, PluginHostActivation, WorkerPluginActivation, + WorkerPluginLoadSpec, load_worker_plugins, }; use nemo_relay::plugin::{ PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins_exact, + list_plugin_kinds, }; use serde_json::{Map, Value as Json, json}; use sha2::{Digest, Sha256}; @@ -41,6 +43,46 @@ fn worker_activation_with_no_specs_is_empty() { activation.clear(); } +#[tokio::test] +async fn plugin_host_activation_owns_worker_lifecycle() { + let _guard = WORKER_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_worker(); + let (_manifest_dir, manifest_ref) = write_manifest(fixture.binary_path()); + let (activation, report) = PluginHostActivation::activate( + PluginConfig::default(), + [DynamicPluginActivationSpec { + plugin_id: "fixture_worker".into(), + kind: DynamicPluginKind::Worker, + manifest_ref: manifest_ref.to_string_lossy().into_owned(), + environment_ref: None, + config: Map::new(), + }], + ) + .await + .expect("worker plugin host should activate"); + + assert!(activation.is_active()); + assert!(!report.has_errors()); + assert!( + list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_worker") + ); + let rewritten = tool_request_intercepts("worker-host-tool", json!({ "input": true })) + .expect("worker host intercept should run"); + assert_eq!(rewritten["worker_plugin"], true); + + activation.clear().expect("worker plugin host should clear"); + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_worker") + ); + let unchanged = tool_request_intercepts("worker-host-tool", json!({ "input": true })) + .expect("cleared worker intercept chain should be empty"); + assert_eq!(unchanged, json!({ "input": true })); +} + #[tokio::test] async fn rust_worker_registers_and_invokes_all_current_surfaces() { let _guard = WORKER_PLUGIN_TEST_LOCK.lock().await; diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index 282c5c571..aceb7fa67 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; use serde_json::json; +use tokio::sync::Notify; use crate::api::llm::{LlmRequest, LlmRequestInterceptOutcome}; use crate::api::llm::{llm_conditional_execution, llm_request_intercepts}; @@ -26,6 +27,17 @@ struct RestoreFailPlugin; struct RestoreBreakPlugin; struct PartialFailPlugin; struct VanishingPlugin; +struct BlockingPlugin { + started: Arc, + release: Arc, + registered: Arc, +} +struct BackgroundTaskPlugin { + release: Arc, + completed: Arc, +} +struct PanickingPlugin; +struct FailingDeregisterPlugin; static RECORDED_NAMES: OnceLock>> = OnceLock::new(); static PARTIAL_FAIL_ROLLBACKS: AtomicUsize = AtomicUsize::new(0); @@ -289,6 +301,116 @@ impl Plugin for VanishingPlugin { } } +impl Plugin for BlockingPlugin { + fn plugin_kind(&self) -> &str { + "blocking.plugin" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + vec![ConfigDiagnostic { + level: DiagnosticLevel::Warning, + code: "blocking.warning".into(), + component: Some("blocking.plugin".into()), + field: None, + message: "blocking plugin validated".into(), + }] + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + let started = Arc::clone(&self.started); + let release = Arc::clone(&self.release); + let registered = Arc::clone(&self.registered); + Box::pin(async move { + started.notify_one(); + release.notified().await; + ctx.add_registration(PluginRegistration::new( + "plugin", + ctx.qualify_name("blocking"), + Box::new(|| Ok(())), + )); + registered.notify_one(); + Ok(()) + }) + } +} + +impl Plugin for BackgroundTaskPlugin { + fn plugin_kind(&self) -> &str { + "background.task.plugin" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + vec![] + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + _ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + let release = Arc::clone(&self.release); + let completed = Arc::clone(&self.completed); + Box::pin(async move { + tokio::spawn(async move { + release.notified().await; + completed.notify_one(); + }); + Ok(()) + }) + } +} + +impl Plugin for PanickingPlugin { + fn plugin_kind(&self) -> &str { + "panicking.plugin" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + vec![] + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + _ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { panic!("fixture plugin panicked during registration") }) + } +} + +impl Plugin for FailingDeregisterPlugin { + fn plugin_kind(&self) -> &str { + "failing.deregister.plugin" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + vec![] + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + ctx.add_registration(PluginRegistration::new( + "fixture", + ctx.qualify_name("refuses-deregistration"), + Box::new(|| { + Err(PluginError::RegistrationFailed( + "fixture deregistration refused".into(), + )) + }), + )); + Ok(()) + }) + } +} + fn reset_global() { crate::shared_runtime::reset_runtime_owner_for_tests(); let ctx = global_context(); @@ -308,6 +430,10 @@ fn reset_global() { let _ = deregister_plugin("restore.break.plugin"); let _ = deregister_plugin("partial.fail.plugin"); let _ = deregister_plugin("vanishing.plugin"); + let _ = deregister_plugin("blocking.plugin"); + let _ = deregister_plugin("background.task.plugin"); + let _ = deregister_plugin("panicking.plugin"); + let _ = deregister_plugin("failing.deregister.plugin"); } #[test] @@ -821,6 +947,16 @@ fn test_rollback_registrations_runs_in_reverse_and_ignores_failures() { }), )); + let panic_order = Arc::clone(&call_order); + registrations.push(PluginRegistration::new( + "plugin", + "panicking", + Box::new(move || { + panic_order.lock().unwrap().push("panicking"); + panic!("expected rollback panic") + }), + )); + let second_order = Arc::clone(&call_order); registrations.push(PluginRegistration::new( "plugin", @@ -836,7 +972,10 @@ fn test_rollback_registrations_runs_in_reverse_and_ignores_failures() { rollback_registrations(&mut registrations); assert!(registrations.is_empty()); - assert_eq!(*call_order.lock().unwrap(), vec!["second", "first"]); + assert_eq!( + *call_order.lock().unwrap(), + vec!["second", "panicking", "first"] + ); } #[test] @@ -885,6 +1024,46 @@ fn test_initialize_plugins_restores_previous_configuration_after_failed_replacem reset_global(); } +#[test] +fn test_initialize_plugins_restores_previous_configuration_after_replacement_panic() { + let _guard = lock_runtime_owner(); + reset_global(); + register_plugin(Arc::new(RecordingPlugin)).unwrap(); + register_plugin(Arc::new(PanickingPlugin)).unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime + .block_on(initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("recording.plugin")], + ..PluginConfig::default() + })) + .unwrap(); + + let error = runtime + .block_on(initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("panicking.plugin")], + ..PluginConfig::default() + })) + .unwrap_err(); + assert!( + error.to_string().contains("fixture plugin panicked"), + "{error}" + ); + assert!(active_plugin_report().is_some()); + assert_eq!( + recorded_names().lock().unwrap().as_slice(), + [ + "__nemo_relay_plugin__recording.plugin__subscriber", + "__nemo_relay_plugin__recording.plugin__subscriber", + ] + ); + + reset_global(); +} + #[test] fn test_initialize_plugins_rolls_back_partial_component_registration_on_failure() { let _guard = lock_runtime_owner(); @@ -914,6 +1093,271 @@ fn test_initialize_plugins_rolls_back_partial_component_registration_on_failure( reset_global(); } +#[test] +fn test_initialize_plugins_transaction_finishes_after_caller_cancellation() { + let _guard = lock_runtime_owner(); + reset_global(); + register_plugin(Arc::new(RecordingPlugin)).unwrap(); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let registered = Arc::new(Notify::new()); + register_plugin(Arc::new(BlockingPlugin { + started: Arc::clone(&started), + release: Arc::clone(&release), + registered: Arc::clone(®istered), + })) + .unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("recording.plugin")], + ..PluginConfig::default() + }) + .await + .unwrap(); + + let caller = tokio::spawn(initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("blocking.plugin")], + ..PluginConfig::default() + })); + started.notified().await; + caller.abort(); + assert!(caller.await.unwrap_err().is_cancelled()); + release.notify_one(); + registered.notified().await; + + for _ in 0..100 { + if active_plugin_report().is_some_and(|report| { + report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "blocking.warning") + }) { + return; + } + tokio::task::yield_now().await; + } + panic!("owned initialization transaction did not finish after caller cancellation"); + }); + + reset_global(); +} + +#[test] +fn test_plugin_runtime_continues_driving_background_tasks_after_initialization() { + let _guard = lock_runtime_owner(); + reset_global(); + let release = Arc::new(Notify::new()); + let completed = Arc::new(Notify::new()); + register_plugin(Arc::new(BackgroundTaskPlugin { + release: Arc::clone(&release), + completed: Arc::clone(&completed), + })) + .unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("background.task.plugin")], + ..PluginConfig::default() + }) + .await + .unwrap(); + + release.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(1), completed.notified()) + .await + .expect("plugin runtime should keep driving spawned background tasks"); + }); + + reset_global(); +} + +#[test] +fn test_plugin_host_activation_cleans_up_after_caller_cancellation() { + let _guard = lock_runtime_owner(); + reset_global(); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let registered = Arc::new(Notify::new()); + register_plugin(Arc::new(BlockingPlugin { + started: Arc::clone(&started), + release: Arc::clone(&release), + registered: Arc::clone(®istered), + })) + .unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let caller = tokio::spawn(dynamic::PluginHostActivation::activate( + PluginConfig { + components: vec![PluginComponentSpec::new("blocking.plugin")], + ..PluginConfig::default() + }, + Vec::::new(), + )); + started.notified().await; + caller.abort(); + match caller.await { + Err(error) => assert!(error.is_cancelled()), + Ok(_) => panic!("plugin host caller should have been canceled"), + } + release.notify_one(); + registered.notified().await; + for _ in 0..100 { + let owner_idle = PLUGIN_MUTATION_OWNER + .lock() + .is_ok_and(|owner| *owner == PluginMutationOwner::Idle); + if owner_idle && active_plugin_report().is_none() { + break; + } + tokio::task::yield_now().await; + } + + // The dropped result must clear its activation and release the lease. + initialize_plugins_exact(PluginConfig::default()) + .await + .expect("canceled host caller should not strand ownership"); + }); + + reset_global(); +} + +#[test] +fn test_pending_registration_records_rollback_failures() { + let failures = Arc::new(Mutex::new(Vec::new())); + { + let mut pending = PendingPluginRegistrations::new(Some(Arc::clone(&failures))); + pending.extend(vec![PluginRegistration::new( + "fixture", + "failed-rollback", + Box::new(|| { + Err(PluginError::RegistrationFailed( + "rollback remained registered".into(), + )) + }), + )]); + } + + let failures = failures.lock().unwrap(); + assert_eq!(failures.len(), 1); + assert!(failures[0].contains("failed-rollback")); + assert!(failures[0].contains("rollback remained registered")); +} + +#[test] +fn test_checked_teardown_reports_unremoved_registrations() { + let _guard = lock_runtime_owner(); + reset_global(); + store_active_plugin_configuration( + PluginConfig::default(), + ConfigReport::default(), + vec![PluginRegistration::new( + "fixture", + "stale-callback", + Box::new(|| { + Err(PluginError::RegistrationFailed( + "deregistration refused".into(), + )) + }), + )], + ) + .unwrap(); + + let outcome = clear_plugin_configuration_inner(); + assert!(!outcome.callbacks_cleared); + let error = outcome.result.unwrap_err().to_string(); + assert!(error.contains("stale-callback"), "{error}"); + assert!(error.contains("deregistration refused"), "{error}"); + assert!(active_plugin_report().is_none()); + reset_global(); +} + +#[test] +fn test_legacy_clear_retains_mutation_owner_after_incomplete_teardown() { + let _guard = lock_runtime_owner(); + reset_global(); + store_active_plugin_configuration( + PluginConfig::default(), + ConfigReport::default(), + vec![PluginRegistration::new( + "fixture", + "stale-callback", + Box::new(|| panic!("fixture deregistration panicked")), + )], + ) + .unwrap(); + + let error = clear_plugin_configuration().unwrap_err(); + assert!(error.to_string().contains("stale-callback"), "{error}"); + assert!( + error + .to_string() + .contains("fixture deregistration panicked"), + "{error}" + ); + assert_eq!( + *PLUGIN_MUTATION_OWNER.lock().unwrap(), + PluginMutationOwner::Legacy + ); + assert!(matches!( + clear_plugin_configuration(), + Err(PluginError::Conflict(_)) + )); + + *PLUGIN_MUTATION_OWNER.lock().unwrap() = PluginMutationOwner::Idle; + reset_global(); +} + +#[test] +fn test_legacy_replace_retains_mutation_owner_after_incomplete_teardown() { + let _guard = lock_runtime_owner(); + reset_global(); + register_plugin(Arc::new(FailingDeregisterPlugin)).unwrap(); + register_plugin(Arc::new(ReplacementPlugin)).unwrap(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime + .block_on(initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("failing.deregister.plugin")], + ..PluginConfig::default() + })) + .unwrap(); + + let error = runtime + .block_on(initialize_plugins_exact(PluginConfig { + components: vec![PluginComponentSpec::new("replacement.plugin")], + ..PluginConfig::default() + })) + .unwrap_err(); + assert!( + error.to_string().contains("fixture deregistration refused"), + "{error}" + ); + assert_eq!(REPLACEMENT_REGISTRATIONS.load(Ordering::SeqCst), 0); + assert_eq!( + *PLUGIN_MUTATION_OWNER.lock().unwrap(), + PluginMutationOwner::Legacy + ); + assert!(active_plugin_report().is_none()); + + *PLUGIN_MUTATION_OWNER.lock().unwrap() = PluginMutationOwner::Idle; + reset_global(); +} + #[test] fn test_initialize_plugins_skips_disabled_components_and_namespaces_multiple_instances() { let _guard = lock_runtime_owner(); From 530dec08aefb89f8cfc07cf741dab0dcea5f7a72 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Mon, 6 Jul 2026 17:57:30 -0600 Subject: [PATCH 02/10] fix(plugin): harden dynamic host teardown Signed-off-by: Bryan Bednarski --- crates/core/src/plugin.rs | 9 +- crates/core/src/plugin/dynamic.rs | 25 +++ crates/core/src/plugin/dynamic/host.rs | 183 +++++++++++++--- crates/core/src/plugin/dynamic/native.rs | 36 +++- crates/core/src/plugin/dynamic/worker.rs | 197 ++++++++++++++++-- .../tests/fixtures/native_plugin/src/lib.rs | 17 ++ .../tests/fixtures/worker_plugin/src/main.rs | 7 + .../tests/integration/native_plugin_tests.rs | 121 ++++++++++- .../tests/integration/worker_plugin_tests.rs | 36 ++++ .../core/tests/unit/dynamic_worker_tests.rs | 1 + 10 files changed, 584 insertions(+), 48 deletions(-) diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 12708a2af..441fe1645 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -802,11 +802,14 @@ fn clone_cached_plugin_error(err: &PluginError) -> PluginError { /// Active component registrations created by previous initialization calls are /// not removed by this function. pub fn deregister_plugin(plugin_kind: &str) -> bool { + deregister_plugin_checked(plugin_kind).unwrap_or(false) +} + +pub(crate) fn deregister_plugin_checked(plugin_kind: &str) -> Result { PLUGIN_HANDLERS .write() - .ok() - .and_then(|mut guard| guard.remove(plugin_kind)) - .is_some() + .map(|mut guard| guard.remove(plugin_kind).is_some()) + .map_err(|err| PluginError::Internal(format!("plugin registry lock poisoned: {err}"))) } /// Lists registered plugin kinds in sorted order. diff --git a/crates/core/src/plugin/dynamic.rs b/crates/core/src/plugin/dynamic.rs index efe26536b..9e9cd98ba 100644 --- a/crates/core/src/plugin/dynamic.rs +++ b/crates/core/src/plugin/dynamic.rs @@ -32,6 +32,31 @@ pub use registry::*; #[cfg(feature = "worker-grpc")] pub use worker::*; +#[derive(Debug)] +pub(crate) struct DynamicPluginTeardownOutcome { + pub(crate) errors: Vec, + pub(crate) safe_to_unload: bool, +} + +impl DynamicPluginTeardownOutcome { + pub(crate) fn success() -> Self { + Self { + errors: Vec::new(), + safe_to_unload: true, + } + } + + pub(crate) fn record_error(&mut self, error: impl Into, safe_to_unload: bool) { + self.errors.push(error.into()); + self.safe_to_unload &= safe_to_unload; + } + + pub(crate) fn merge(&mut self, other: Self) { + self.errors.extend(other.errors); + self.safe_to_unload &= other.safe_to_unload; + } +} + /// Plugin execution lane. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] diff --git a/crates/core/src/plugin/dynamic/host.rs b/crates/core/src/plugin/dynamic/host.rs index 3244ea632..c85061bba 100644 --- a/crates/core/src/plugin/dynamic/host.rs +++ b/crates/core/src/plugin/dynamic/host.rs @@ -3,6 +3,7 @@ //! Owned activation lifecycle for dynamically loaded plugin components. +use std::collections::HashSet; use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; @@ -11,10 +12,14 @@ use serde_json::{Map, Value as Json}; use crate::plugin::{ ConfigReport, PluginComponentSpec, PluginConfig, PluginHostLease, Result, acquire_plugin_host_lease, clear_plugin_configuration_for_host, - initialize_plugins_exact_for_host, run_owned_plugin_mutation, + ensure_builtin_plugins_registered, initialize_plugins_exact_for_host, + run_owned_plugin_mutation, }; -use super::{DynamicPluginKind, NativePluginActivation, NativePluginLoadSpec, load_native_plugins}; +use super::{ + DynamicPluginKind, DynamicPluginTeardownOutcome, NativePluginActivation, NativePluginLoadSpec, + load_native_plugins, +}; #[cfg(feature = "worker-grpc")] use super::{WorkerPluginActivation, WorkerPluginLoadSpec, load_worker_plugins}; @@ -76,6 +81,7 @@ impl PluginHostActivation { dynamic_plugins: Vec, ) -> Result<(Self, ConfigReport)> { let claim = acquire_plugin_host_lease()?; + validate_unique_plugin_ids(&dynamic_plugins)?; #[cfg(not(feature = "worker-grpc"))] if let Some(plugin) = dynamic_plugins @@ -88,6 +94,11 @@ impl PluginHostActivation { ))); } + // Builtin registration is cached process-wide. It must complete before + // a dynamic plugin can claim a reserved builtin kind and permanently + // cache a failed builtin registration attempt. + ensure_builtin_plugins_registered()?; + let native_specs = dynamic_plugins .iter() .filter(|plugin| plugin.kind == DynamicPluginKind::RustDynamic) @@ -211,39 +222,101 @@ impl PluginHostActivation { result: Ok(()), callbacks_cleared: true, }); - if outcome.callbacks_cleared { - self.native.take(); - #[cfg(feature = "worker-grpc")] - self.worker.take(); - self.claim.take(); - } else { + let mut errors = outcome + .result + .err() + .map(|error| vec![error.to_string()]) + .unwrap_or_default(); + if !outcome.callbacks_cleared { // If core could not prove callbacks were removed, intentionally // retain their code and owner for process lifetime rather than // unload a library or worker that may still be referenced. - if let Some(native) = self.native.take() { - std::mem::forget(native); - } - #[cfg(feature = "worker-grpc")] - if let Some(worker) = self.worker.take() { - std::mem::forget(worker); - } - if let Some(claim) = self.claim.take() { - std::mem::forget(claim); - } + self.retain_loaded_runtimes(); + return Err(retained_runtime_error(errors)); + } + + let mut runtime_outcome = DynamicPluginTeardownOutcome::success(); + if let Some(native) = &mut self.native { + runtime_outcome.merge(native.deregister_plugin_kinds_checked()); + } + #[cfg(feature = "worker-grpc")] + if let Some(worker) = &mut self.worker { + runtime_outcome.merge(worker.deregister_plugin_kinds_checked()); + } + + // A worker cannot be stopped while its registry adapter might still be + // callable. Only begin process shutdown once every kind is known to be + // absent from the registry. + #[cfg(feature = "worker-grpc")] + if runtime_outcome.safe_to_unload + && let Some(worker) = &self.worker + { + runtime_outcome.merge(worker.shutdown_plugins_checked()); } - if outcome.callbacks_cleared { - outcome.result + errors.extend(runtime_outcome.errors); + + if !runtime_outcome.safe_to_unload { + self.retain_loaded_runtimes(); + return Err(retained_runtime_error(errors)); + } + + // Callback removal and kind deregistration are now complete. Dropping + // the activations unloads libraries and runtimes before releasing the + // process-wide host claim. + self.native.take(); + #[cfg(feature = "worker-grpc")] + self.worker.take(); + self.claim.take(); + + if errors.is_empty() { + Ok(()) } else { Err(crate::plugin::PluginError::RegistrationFailed(format!( - "{}; the loaded runtimes were retained because callbacks may remain registered", - outcome - .result - .err() - .map(|error| error.to_string()) - .unwrap_or_else(|| "plugin teardown was incomplete".into()) + "dynamic plugin teardown failed: {}", + errors.join("; ") ))) } } + + fn retain_loaded_runtimes(&mut self) { + if let Some(native) = self.native.take() { + std::mem::forget(native); + } + #[cfg(feature = "worker-grpc")] + if let Some(worker) = self.worker.take() { + std::mem::forget(worker); + } + if let Some(claim) = self.claim.take() { + std::mem::forget(claim); + } + } +} + +fn validate_unique_plugin_ids(dynamic_plugins: &[DynamicPluginActivationSpec]) -> Result<()> { + let mut plugin_ids = HashSet::with_capacity(dynamic_plugins.len()); + for plugin in dynamic_plugins { + if !plugin_ids.insert(plugin.plugin_id.as_str()) { + return Err(crate::plugin::PluginError::InvalidConfig(format!( + "duplicate dynamic plugin id '{}'", + plugin.plugin_id + ))); + } + } + Ok(()) +} + +fn retained_runtime_error(errors: Vec) -> crate::plugin::PluginError { + crate::plugin::PluginError::RegistrationFailed(format!( + concat!( + "{}; the loaded runtimes and activation owner were retained because safe ", + "unloading could not be proven" + ), + if errors.is_empty() { + "dynamic plugin teardown was incomplete".into() + } else { + errors.join("; ") + } + )) } fn plugin_error_context( @@ -273,3 +346,61 @@ impl Drop for PluginHostActivation { let _ = self.clear_inner(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::{PLUGIN_HANDLERS, PLUGIN_MUTATION_OWNER, PluginMutationOwner}; + + struct PoisonedRegistryCleanup; + + impl Drop for PoisonedRegistryCleanup { + fn drop(&mut self) { + PLUGIN_HANDLERS.clear_poison(); + if let Ok(mut owner) = PLUGIN_MUTATION_OWNER.lock() { + *owner = PluginMutationOwner::Idle; + } + } + } + + #[test] + fn unsafe_kind_deregistration_retains_runtime_and_owner() { + let _guard = crate::shared_runtime::runtime_owner_test_mutex() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let _cleanup = PoisonedRegistryCleanup; + let claim = acquire_plugin_host_lease().expect("fixture host should acquire the owner"); + let owner_id = claim.owner_id(); + let activation = PluginHostActivation { + active: true, + native: Some(NativePluginActivation::with_plugin_kind_for_test( + "fixture.poisoned", + )), + #[cfg(feature = "worker-grpc")] + worker: None, + claim: Some(claim), + }; + + std::thread::spawn(|| { + let _registry = PLUGIN_HANDLERS.write().unwrap(); + panic!("poison plugin registry for teardown test"); + }) + .join() + .expect_err("fixture registry writer should panic"); + + let error = activation + .clear() + .expect_err("an uncertain kind deregistration must retain the activation") + .to_string(); + assert!(error.contains("plugin registry lock poisoned"), "{error}"); + assert!(error.contains("activation owner were retained"), "{error}"); + assert_eq!( + *PLUGIN_MUTATION_OWNER.lock().unwrap(), + PluginMutationOwner::Host(owner_id) + ); + assert!(matches!( + acquire_plugin_host_lease(), + Err(crate::plugin::PluginError::Conflict(_)) + )); + } +} diff --git a/crates/core/src/plugin/dynamic/native.rs b/crates/core/src/plugin/dynamic/native.rs index f612e884e..7291675cf 100644 --- a/crates/core/src/plugin/dynamic/native.rs +++ b/crates/core/src/plugin/dynamic/native.rs @@ -52,10 +52,13 @@ use crate::api::tool::ToolExecutionInterceptOutcome; use crate::error::{FlowError, Result as FlowResult}; use crate::plugin::{ ConfigDiagnostic, DiagnosticLevel, Plugin, PluginError, PluginRegistrationContext, - deregister_plugin, register_plugin, + deregister_plugin, deregister_plugin_checked, register_plugin, }; -use super::{DynamicPluginKind, DynamicPluginManifest, DynamicPluginManifestLoad}; +use super::{ + DynamicPluginKind, DynamicPluginManifest, DynamicPluginManifestLoad, + DynamicPluginTeardownOutcome, +}; /// Native plugin load request derived from host dynamic-plugin state. #[derive(Debug, Clone, PartialEq, Eq)] @@ -84,6 +87,35 @@ impl NativePluginActivation { /// Consumes the activation and deregisters loaded plugin kinds. pub fn clear(self) {} + + pub(crate) fn deregister_plugin_kinds_checked(&mut self) -> DynamicPluginTeardownOutcome { + let mut outcome = DynamicPluginTeardownOutcome::success(); + let plugin_kinds = std::mem::take(&mut self.plugin_kinds); + for plugin_kind in plugin_kinds.into_iter().rev() { + match deregister_plugin_checked(&plugin_kind) { + Ok(true) => {} + Ok(false) => outcome.record_error( + format!( + "native plugin kind '{plugin_kind}' was not registered during teardown" + ), + true, + ), + Err(error) => outcome.record_error( + format!("failed to deregister native plugin kind '{plugin_kind}': {error}"), + false, + ), + } + } + outcome + } + + #[cfg(test)] + pub(super) fn with_plugin_kind_for_test(plugin_kind: impl Into) -> Self { + Self { + plugins: Vec::new(), + plugin_kinds: vec![plugin_kind.into()], + } + } } impl Drop for NativePluginActivation { diff --git a/crates/core/src/plugin/dynamic/worker.rs b/crates/core/src/plugin/dynamic/worker.rs index d8424043c..c4ffefe6a 100644 --- a/crates/core/src/plugin/dynamic/worker.rs +++ b/crates/core/src/plugin/dynamic/worker.rs @@ -8,6 +8,7 @@ use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; @@ -66,10 +67,13 @@ use crate::codec::request::AnnotatedLlmRequest; use crate::error::{FlowError, Result as FlowResult}; use crate::plugin::{ ConfigDiagnostic, DiagnosticLevel, Plugin, PluginError, PluginRegistrationContext, - deregister_plugin, register_plugin, + deregister_plugin, deregister_plugin_checked, register_plugin, }; -use super::{DynamicPluginKind, DynamicPluginManifest, DynamicPluginManifestLoad, WorkerRuntime}; +use super::{ + DynamicPluginKind, DynamicPluginManifest, DynamicPluginManifestLoad, + DynamicPluginTeardownOutcome, WorkerRuntime, +}; const JSON_SCHEMA: &str = "nemo.relay.Json@1"; const EVENT_SCHEMA: &str = "nemo.relay.Event@1"; @@ -129,6 +133,35 @@ impl WorkerPluginActivation { /// Consumes the activation; deregistration runs from `Drop`. pub fn clear(self) {} + + pub(crate) fn deregister_plugin_kinds_checked(&mut self) -> DynamicPluginTeardownOutcome { + let mut outcome = DynamicPluginTeardownOutcome::success(); + let plugin_kinds = std::mem::take(&mut self.plugin_kinds); + for plugin_kind in plugin_kinds.into_iter().rev() { + match deregister_plugin_checked(&plugin_kind) { + Ok(true) => {} + Ok(false) => outcome.record_error( + format!( + "worker plugin kind '{plugin_kind}' was not registered during teardown" + ), + true, + ), + Err(error) => outcome.record_error( + format!("failed to deregister worker plugin kind '{plugin_kind}': {error}"), + false, + ), + } + } + outcome + } + + pub(crate) fn shutdown_plugins_checked(&self) -> DynamicPluginTeardownOutcome { + let mut outcome = DynamicPluginTeardownOutcome::success(); + for plugin in self.plugins.iter().rev() { + outcome.merge(plugin.shutdown_checked()); + } + outcome + } } impl Drop for WorkerPluginActivation { @@ -221,32 +254,165 @@ struct WorkerPluginInstance { shutdown: Mutex>>, process: Mutex>, activation_dir: PathBuf, + teardown_started: AtomicBool, } impl Drop for WorkerPluginInstance { fn drop(&mut self) { + let _ = self.shutdown_checked(); + } +} + +impl WorkerPluginInstance { + fn shutdown_checked(&self) -> DynamicPluginTeardownOutcome { + let mut outcome = DynamicPluginTeardownOutcome::success(); + if self.teardown_started.swap(true, Ordering::AcqRel) { + return outcome; + } + let mut client = self.client.clone(); let request = ShutdownRequest { activation_id: self.host_state.activation_id.clone(), auth_token: self.host_state.auth_token.clone(), - reason: "plugin activation dropped".into(), + reason: "plugin activation cleared".into(), }; - let _ = block_on_runtime(self.runtime.runtime(), async move { - worker_rpc(client.shutdown(worker_rpc_request(request))).await - }); - if let Ok(mut shutdown) = self.shutdown.lock() - && let Some(sender) = shutdown.take() - { - let _ = sender.send(()); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + block_on_runtime(self.runtime.runtime(), async move { + worker_rpc(client.shutdown(worker_rpc_request(request))).await + }) + })) { + Ok(Ok(_)) => {} + Ok(Err(error)) => outcome.record_error( + format!( + "worker plugin '{}' shutdown RPC failed: {error}", + self.plugin_kind + ), + true, + ), + Err(payload) => outcome.record_error( + format!( + "worker plugin '{}' shutdown RPC panicked: {}", + self.plugin_kind, + panic_payload_message(payload.as_ref()) + ), + true, + ), + } + + match self.shutdown.lock() { + Ok(mut shutdown) => { + if let Some(sender) = shutdown.take() + && sender.send(()).is_err() + { + outcome.record_error( + format!( + "worker plugin '{}' host runtime shutdown channel was closed", + self.plugin_kind + ), + true, + ); + } + } + Err(error) => outcome.record_error( + format!( + "worker plugin '{}' host runtime shutdown lock poisoned: {error}", + self.plugin_kind + ), + true, + ), } - if let Ok(mut process) = self.process.lock() - && let Some(mut child) = process.take() + + self.stop_process_checked(&mut outcome); + if outcome.safe_to_unload + && let Err(error) = std::fs::remove_dir_all(&self.activation_dir) + && error.kind() != std::io::ErrorKind::NotFound { - let _ = child.kill(); - let _ = child.wait(); + outcome.record_error( + format!( + "worker plugin '{}' activation directory cleanup failed for '{}': {error}", + self.plugin_kind, + self.activation_dir.display() + ), + true, + ); } - let _ = std::fs::remove_dir_all(&self.activation_dir); + outcome } + + fn stop_process_checked(&self, outcome: &mut DynamicPluginTeardownOutcome) { + let mut process = match self.process.lock() { + Ok(process) => process, + Err(error) => { + outcome.record_error( + format!( + "worker plugin '{}' process lock poisoned: {error}", + self.plugin_kind + ), + false, + ); + return; + } + }; + let Some(child) = process.as_mut() else { + return; + }; + match child.try_wait() { + Ok(Some(_)) => { + process.take(); + } + Ok(None) => { + if let Err(kill_error) = child.kill() { + match child.try_wait() { + Ok(Some(_)) => { + process.take(); + outcome.record_error( + format!( + "worker plugin '{}' process kill failed after exit: {kill_error}", + self.plugin_kind + ), + true, + ); + } + Ok(None) | Err(_) => outcome.record_error( + format!( + "worker plugin '{}' process kill failed: {kill_error}", + self.plugin_kind + ), + false, + ), + } + return; + } + match child.wait() { + Ok(_) => { + process.take(); + } + Err(error) => outcome.record_error( + format!( + "worker plugin '{}' process wait failed after kill: {error}", + self.plugin_kind + ), + false, + ), + } + } + Err(error) => outcome.record_error( + format!( + "worker plugin '{}' process status check failed: {error}", + self.plugin_kind + ), + false, + ), + } + } +} + +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> &str { + payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("unknown panic payload") } fn load_one_worker_plugin( @@ -426,6 +592,7 @@ fn load_one_worker_plugin( shutdown: Mutex::new(Some(shutdown_tx)), process: Mutex::new(Some(child.take())), activation_dir: activation_dir_guard.keep(), + teardown_started: AtomicBool::new(false), })) } diff --git a/crates/core/tests/fixtures/native_plugin/src/lib.rs b/crates/core/tests/fixtures/native_plugin/src/lib.rs index 4886a6c96..acca59044 100644 --- a/crates/core/tests/fixtures/native_plugin/src/lib.rs +++ b/crates/core/tests/fixtures/native_plugin/src/lib.rs @@ -260,6 +260,23 @@ fn mark_json(mut value: Json, key: &str) -> Json { nemo_relay_plugin::nemo_relay_plugin!(nemo_relay_fixture_native_plugin, || FixtureNativePlugin); +#[unsafe(no_mangle)] +pub unsafe extern "C" fn nemo_relay_fixture_observability_collision( + host: *const NemoRelayNativeHostApiV1, + out: *mut NemoRelayNativePluginV1, +) -> NemoRelayStatus { + unsafe { + write_raw_descriptor( + host, + out, + "observability", + None, + None, + Some(raw_noop_register), + ) + } +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn nemo_relay_fixture_entry_error( host: *const NemoRelayNativeHostApiV1, diff --git a/crates/core/tests/fixtures/worker_plugin/src/main.rs b/crates/core/tests/fixtures/worker_plugin/src/main.rs index 8fe888cf6..2d84ec907 100644 --- a/crates/core/tests/fixtures/worker_plugin/src/main.rs +++ b/crates/core/tests/fixtures/worker_plugin/src/main.rs @@ -79,6 +79,10 @@ impl WorkerPlugin for FixtureWorkerPlugin { .get("tool_request_error") .and_then(Json::as_bool) .unwrap_or(false); + let exit_in_tool_request = config + .get("exit_in_tool_request") + .and_then(Json::as_bool) + .unwrap_or(false); let llm_request_error = config .get("llm_request_error") .and_then(Json::as_bool) @@ -136,6 +140,9 @@ impl WorkerPlugin for FixtureWorkerPlugin { ctx.register_tool_request_intercept("fixture_rewrite_args", 0, false, { let runtime = runtime.clone(); move |_name, args| { + if exit_in_tool_request { + std::process::exit(44); + } if tool_request_error { return Err(WorkerSdkError::Callback( "fixture tool request error requested".into(), diff --git a/crates/core/tests/integration/native_plugin_tests.rs b/crates/core/tests/integration/native_plugin_tests.rs index e6e8e70ba..3451d4955 100644 --- a/crates/core/tests/integration/native_plugin_tests.rs +++ b/crates/core/tests/integration/native_plugin_tests.rs @@ -28,8 +28,8 @@ use nemo_relay::plugin::dynamic::{ load_native_plugins, }; use nemo_relay::plugin::{ - PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins_exact, - list_plugin_kinds, + PluginComponentSpec, PluginConfig, clear_plugin_configuration, deregister_plugin, + initialize_plugins_exact, list_plugin_kinds, }; use serde_json::{Map, Value as Json, json}; use sha2::{Digest, Sha256}; @@ -1042,6 +1042,108 @@ async fn plugin_host_activation_drop_releases_owner_and_plugin_kind() { activation.clear().expect("second plugin host should clear"); } +#[tokio::test] +async fn plugin_host_reserved_id_failure_does_not_poison_future_activation() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let collision_manifest = write_manifest_with_plugin_id_and_symbol( + &fixture, + "observability", + "nemo_relay_fixture_observability_collision", + ); + + let error = match PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("observability", &collision_manifest)], + ) + .await + { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected collision activation should clear"); + panic!("a dynamic plugin must not replace a builtin kind"); + } + Err(error) => error.to_string(), + }; + assert!(error.contains("observability"), "{error}"); + assert!(error.contains("already registered"), "{error}"); + + let manifest_ref = write_manifest(&fixture); + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("a reserved-id failure must not poison later activation"); + activation + .clear() + .expect("recovered plugin host should clear"); +} + +#[tokio::test] +async fn plugin_host_rejects_duplicate_ids_before_loading() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let spec = host_spec("fixture_native", &manifest_ref); + + let error = + match PluginHostActivation::activate(PluginConfig::default(), [spec.clone(), spec]).await { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected duplicate activation should clear"); + panic!("duplicate dynamic plugin ids should fail"); + } + Err(error) => error.to_string(), + }; + assert!(error.contains("duplicate dynamic plugin id"), "{error}"); + assert!(error.contains("fixture_native"), "{error}"); + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "fixture_native") + ); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("duplicate-id rejection must release the owner"); + activation.clear().expect("recovered host should clear"); +} + +#[tokio::test] +async fn plugin_host_clear_surfaces_missing_kind_and_releases_safe_owner() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("plugin host should activate"); + + assert!(deregister_plugin("fixture_native")); + let error = activation + .clear() + .expect_err("missing plugin-kind deregistration should be surfaced") + .to_string(); + assert!(error.contains("fixture_native"), "{error}"); + assert!(error.contains("was not registered"), "{error}"); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("a safely absent plugin kind should release the owner"); + activation.clear().expect("recovered host should clear"); +} + #[tokio::test] async fn plugin_host_partial_load_failure_rolls_back_and_releases_owner() { let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; @@ -1305,6 +1407,21 @@ fn write_manifest_with_symbol(fixture: &BuiltFixture, symbol: &str) -> PathBuf { }) } +fn write_manifest_with_plugin_id_and_symbol( + fixture: &BuiltFixture, + plugin_id: &str, + symbol: &str, +) -> PathBuf { + write_manifest_text(ManifestOptions { + manifest_dir: fixture.manifest_dir.path(), + plugin_id, + relay: &format!("={}", env!("CARGO_PKG_VERSION")), + library: &fixture.library_path.to_string_lossy(), + symbol, + integrity: None, + }) +} + fn write_manifest_with_plugin_id(fixture: &BuiltFixture, plugin_id: &str) -> PathBuf { write_manifest_text(ManifestOptions { manifest_dir: fixture.manifest_dir.path(), diff --git a/crates/core/tests/integration/worker_plugin_tests.rs b/crates/core/tests/integration/worker_plugin_tests.rs index 2efc55111..e16173ef8 100644 --- a/crates/core/tests/integration/worker_plugin_tests.rs +++ b/crates/core/tests/integration/worker_plugin_tests.rs @@ -83,6 +83,42 @@ async fn plugin_host_activation_owns_worker_lifecycle() { assert_eq!(unchanged, json!({ "input": true })); } +#[tokio::test] +async fn plugin_host_clear_surfaces_worker_shutdown_failure_and_releases_safe_owner() { + let _guard = WORKER_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_worker(); + let (_manifest_dir, manifest_ref) = write_manifest(fixture.binary_path()); + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [DynamicPluginActivationSpec { + plugin_id: "fixture_worker".into(), + kind: DynamicPluginKind::Worker, + manifest_ref: manifest_ref.to_string_lossy().into_owned(), + environment_ref: None, + config: Map::from_iter([("exit_in_tool_request".into(), json!(true))]), + }], + ) + .await + .expect("worker plugin host should activate"); + + tool_request_intercepts("terminate-worker", json!({ "input": true })) + .expect_err("fixture worker should terminate during callback"); + let error = activation + .clear() + .expect_err("worker shutdown failure should be surfaced") + .to_string(); + assert!(error.contains("fixture_worker"), "{error}"); + assert!(error.contains("shutdown"), "{error}"); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + Vec::::new(), + ) + .await + .expect("a stopped worker process should permit owner release"); + activation.clear().expect("recovered host should clear"); +} + #[tokio::test] async fn rust_worker_registers_and_invokes_all_current_surfaces() { let _guard = WORKER_PLUGIN_TEST_LOCK.lock().await; diff --git a/crates/core/tests/unit/dynamic_worker_tests.rs b/crates/core/tests/unit/dynamic_worker_tests.rs index cd8abf3b8..a3c71270d 100644 --- a/crates/core/tests/unit/dynamic_worker_tests.rs +++ b/crates/core/tests/unit/dynamic_worker_tests.rs @@ -1465,6 +1465,7 @@ async fn fake_worker_instance( shutdown: Mutex::new(None), process: Mutex::new(None), activation_dir, + teardown_started: AtomicBool::new(false), }, shutdown_tx, ) From c73af5496ab264833aeee6fae891b61cd0db6773 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Mon, 6 Jul 2026 18:30:59 -0600 Subject: [PATCH 03/10] fix(plugin): preserve registry ownership Signed-off-by: Bryan Bednarski --- crates/core/Cargo.toml | 4 + .../src/observability/plugin_component.rs | 10 +- crates/core/src/plugin.rs | 118 +++++++++++++----- crates/core/src/plugin/dynamic/native.rs | 36 +++--- crates/core/src/plugin/dynamic/worker.rs | 34 +++-- crates/core/src/plugins/model_pricing.rs | 12 +- .../src/plugins/nemo_guardrails/component.rs | 10 +- .../tests/integration/native_plugin_tests.rs | 71 ++++++++++- .../plugin_host_builtin_ownership_tests.rs | 73 +++++++++++ 9 files changed, 281 insertions(+), 87 deletions(-) create mode 100644 crates/core/tests/integration/plugin_host_builtin_ownership_tests.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 13c2096ad..fe005dca8 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -120,6 +120,10 @@ path = "tests/integration/context_isolation_tests.rs" name = "native_plugin_integration" path = "tests/integration/native_plugin_tests.rs" +[[test]] +name = "plugin_host_builtin_ownership" +path = "tests/integration/plugin_host_builtin_ownership_tests.rs" + [[test]] name = "worker_plugin_integration" path = "tests/integration/worker_plugin_tests.rs" diff --git a/crates/core/src/observability/plugin_component.rs b/crates/core/src/observability/plugin_component.rs index ff8e62197..2e3ba4210 100644 --- a/crates/core/src/observability/plugin_component.rs +++ b/crates/core/src/observability/plugin_component.rs @@ -54,7 +54,7 @@ use crate::observability::otel::{ use crate::plugin::{ ConfigDiagnostic, ConfigPolicy, DiagnosticLevel, Plugin, PluginComponentSpec, PluginError, PluginRegistration, PluginRegistrationContext, Result as PluginResult, UnsupportedBehavior, - deregister_plugin, register_plugin, + deregister_plugin, register_builtin_plugin, }; /// The plugin kind registered by the core crate. @@ -533,13 +533,7 @@ impl Plugin for ObservabilityPlugin { /// automatically before listing, looking up, validating, or initializing plugin /// components, so applications normally do not need to invoke it directly. pub fn register_observability_component() -> PluginResult<()> { - match register_plugin(Arc::new(ObservabilityPlugin)) { - Ok(()) => Ok(()), - Err(PluginError::RegistrationFailed(message)) if message.contains("already registered") => { - Ok(()) - } - Err(err) => Err(err), - } + register_builtin_plugin(Arc::new(ObservabilityPlugin)) } /// Deregisters the observability component kind from the core plugin registry. diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 441fe1645..0da279ecf 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -46,15 +46,34 @@ pub use nemo_relay_types::plugin::{ConfigDiagnostic, DiagnosticLevel}; pub mod dynamic; pub use dynamic::*; -type PluginMap = HashMap>; +type PluginMap = HashMap; + +struct RegisteredPlugin { + registration_id: u64, + owner: PluginRegistrationOwner, + plugin: Arc, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PluginRegistrationOwner { + Builtin, + External, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PluginDeregistrationOutcome { + Removed, + Missing, + Replaced, +} static PLUGIN_HANDLERS: LazyLock> = LazyLock::new(|| RwLock::new(HashMap::new())); static ACTIVE_PLUGIN_CONFIGURATION: LazyLock>> = LazyLock::new(|| Mutex::new(None)); static PLUGIN_MUTATION_OWNER: LazyLock> = LazyLock::new(|| Mutex::new(PluginMutationOwner::Idle)); +static NEXT_PLUGIN_REGISTRATION_ID: AtomicU64 = AtomicU64::new(1); static NEXT_PLUGIN_HOST_OWNER_ID: AtomicU64 = AtomicU64::new(1); -static BUILTIN_PLUGIN_REGISTRATION: OnceLock> = OnceLock::new(); static PLUGIN_MUTATION_EXECUTOR: OnceLock = OnceLock::new(); type PluginMutationJob = Pin + Send + 'static>>; @@ -744,17 +763,50 @@ pub trait Plugin: Send + Sync + 'static { /// # Notes /// Registration affects future validation and initialization only. pub fn register_plugin(plugin: Arc) -> Result<()> { + register_plugin_with_owner(plugin, PluginRegistrationOwner::External).map(|_| ()) +} + +pub(crate) fn register_plugin_tracked(plugin: Arc) -> Result { + register_plugin_with_owner(plugin, PluginRegistrationOwner::External) +} + +pub(crate) fn register_builtin_plugin(plugin: Arc) -> Result<()> { + register_plugin_with_owner(plugin, PluginRegistrationOwner::Builtin).map(|_| ()) +} + +fn register_plugin_with_owner( + plugin: Arc, + owner: PluginRegistrationOwner, +) -> Result { let mut guard = PLUGIN_HANDLERS .write() .map_err(|err| PluginError::Internal(format!("plugin registry lock poisoned: {err}")))?; let plugin_kind = plugin.plugin_kind().to_string(); - if guard.contains_key(&plugin_kind) { + if let Some(existing) = guard.get(&plugin_kind) { + if owner == PluginRegistrationOwner::Builtin + && existing.owner == PluginRegistrationOwner::Builtin + { + return Ok(existing.registration_id); + } + let ownership = if owner == PluginRegistrationOwner::Builtin { + "reserved builtin " + } else { + "" + }; return Err(PluginError::RegistrationFailed(format!( - "plugin '{plugin_kind}' is already registered" + "{ownership}plugin '{plugin_kind}' is already registered" ))); } - guard.insert(plugin_kind, plugin); - Ok(()) + let registration_id = NEXT_PLUGIN_REGISTRATION_ID.fetch_add(1, Ordering::Relaxed); + guard.insert( + plugin_kind, + RegisteredPlugin { + registration_id, + owner, + plugin, + }, + ); + Ok(registration_id) } /// Registers core-provided plugin kinds. @@ -762,28 +814,12 @@ pub fn register_plugin(plugin: Arc) -> Result<()> { /// Built-in plugins are available to validation and initialization without a /// binding or application-specific registration call. pub fn ensure_builtin_plugins_registered() -> Result<()> { - let register_builtins = || { - crate::observability::plugin_component::register_observability_component()?; - crate::plugins::nemo_guardrails::component::register_nemo_guardrails_component()?; - crate::plugins::model_pricing::register_pricing_component() - }; - match BUILTIN_PLUGIN_REGISTRATION.get_or_init(register_builtins) { - Ok(()) => Ok(()), - Err(err) => Err(clone_cached_plugin_error(err)), - } -} - -fn clone_cached_plugin_error(err: &PluginError) -> PluginError { - match err { - PluginError::InvalidConfig(message) => PluginError::InvalidConfig(message.clone()), - PluginError::Conflict(message) => PluginError::Conflict(message.clone()), - PluginError::NotFound(message) => PluginError::NotFound(message.clone()), - PluginError::Serialization(err) => PluginError::Internal(err.to_string()), - PluginError::Internal(message) => PluginError::Internal(message.clone()), - PluginError::RegistrationFailed(message) => { - PluginError::RegistrationFailed(message.clone()) - } - } + // Registration is idempotent for genuine built-ins. Revalidate on every + // call so a removed built-in is restored, a replacement is rejected, and + // a corrected ownership conflict can be retried without restarting Relay. + crate::observability::plugin_component::register_observability_component()?; + crate::plugins::nemo_guardrails::component::register_nemo_guardrails_component()?; + crate::plugins::model_pricing::register_pricing_component() } /// Removes a previously registered plugin. @@ -812,6 +848,23 @@ pub(crate) fn deregister_plugin_checked(plugin_kind: &str) -> Result { .map_err(|err| PluginError::Internal(format!("plugin registry lock poisoned: {err}"))) } +pub(crate) fn deregister_plugin_registration_checked( + plugin_kind: &str, + expected_registration_id: u64, +) -> Result { + let mut guard = PLUGIN_HANDLERS + .write() + .map_err(|err| PluginError::Internal(format!("plugin registry lock poisoned: {err}")))?; + match guard.get(plugin_kind) { + Some(plugin) if plugin.registration_id == expected_registration_id => { + guard.remove(plugin_kind); + Ok(PluginDeregistrationOutcome::Removed) + } + Some(_) => Ok(PluginDeregistrationOutcome::Replaced), + None => Ok(PluginDeregistrationOutcome::Missing), + } +} + /// Lists registered plugin kinds in sorted order. /// /// This returns the currently registered plugin kinds without inspecting the @@ -846,10 +899,11 @@ pub fn list_plugin_kinds() -> Vec { /// The returned plugin is shared by [`Arc`], so callers receive a cheap clone. pub fn lookup_plugin(plugin_kind: &str) -> Option> { let _ = ensure_builtin_plugins_registered(); - PLUGIN_HANDLERS - .read() - .ok() - .and_then(|guard| guard.get(plugin_kind).cloned()) + PLUGIN_HANDLERS.read().ok().and_then(|guard| { + guard + .get(plugin_kind) + .map(|registered| Arc::clone(®istered.plugin)) + }) } /// Validates a plugin configuration document. diff --git a/crates/core/src/plugin/dynamic/native.rs b/crates/core/src/plugin/dynamic/native.rs index 7291675cf..27136d3f6 100644 --- a/crates/core/src/plugin/dynamic/native.rs +++ b/crates/core/src/plugin/dynamic/native.rs @@ -51,8 +51,8 @@ use crate::api::scope::{event as emit_scope_mark, get_handle, pop_scope, push_sc use crate::api::tool::ToolExecutionInterceptOutcome; use crate::error::{FlowError, Result as FlowResult}; use crate::plugin::{ - ConfigDiagnostic, DiagnosticLevel, Plugin, PluginError, PluginRegistrationContext, - deregister_plugin, deregister_plugin_checked, register_plugin, + ConfigDiagnostic, DiagnosticLevel, Plugin, PluginDeregistrationOutcome, PluginError, + PluginRegistrationContext, deregister_plugin_registration_checked, register_plugin_tracked, }; use super::{ @@ -76,7 +76,7 @@ pub struct NativePluginLoadSpec { /// runtime callbacks cannot outlive their code. pub struct NativePluginActivation { plugins: Vec>, - plugin_kinds: Vec, + plugin_registrations: Vec<(String, u64)>, } impl NativePluginActivation { @@ -90,16 +90,22 @@ impl NativePluginActivation { pub(crate) fn deregister_plugin_kinds_checked(&mut self) -> DynamicPluginTeardownOutcome { let mut outcome = DynamicPluginTeardownOutcome::success(); - let plugin_kinds = std::mem::take(&mut self.plugin_kinds); - for plugin_kind in plugin_kinds.into_iter().rev() { - match deregister_plugin_checked(&plugin_kind) { - Ok(true) => {} - Ok(false) => outcome.record_error( + let plugin_registrations = std::mem::take(&mut self.plugin_registrations); + for (plugin_kind, registration_id) in plugin_registrations.into_iter().rev() { + match deregister_plugin_registration_checked(&plugin_kind, registration_id) { + Ok(PluginDeregistrationOutcome::Removed) => {} + Ok(PluginDeregistrationOutcome::Missing) => outcome.record_error( format!( "native plugin kind '{plugin_kind}' was not registered during teardown" ), true, ), + Ok(PluginDeregistrationOutcome::Replaced) => outcome.record_error( + format!( + "native plugin kind '{plugin_kind}' was replaced during teardown and was left registered" + ), + true, + ), Err(error) => outcome.record_error( format!("failed to deregister native plugin kind '{plugin_kind}': {error}"), false, @@ -113,15 +119,15 @@ impl NativePluginActivation { pub(super) fn with_plugin_kind_for_test(plugin_kind: impl Into) -> Self { Self { plugins: Vec::new(), - plugin_kinds: vec![plugin_kind.into()], + plugin_registrations: vec![(plugin_kind.into(), 0)], } } } impl Drop for NativePluginActivation { fn drop(&mut self) { - for plugin_kind in self.plugin_kinds.iter().rev() { - let _ = deregister_plugin(plugin_kind); + for (plugin_kind, registration_id) in self.plugin_registrations.iter().rev() { + let _ = deregister_plugin_registration_checked(plugin_kind, *registration_id); } } } @@ -136,18 +142,20 @@ where { let mut activation = NativePluginActivation { plugins: Vec::new(), - plugin_kinds: Vec::new(), + plugin_registrations: Vec::new(), }; for spec in specs { let instance = load_one_native_plugin(&spec)?; let plugin_kind = instance.plugin_kind.clone(); - register_plugin(Arc::new(NativePluginAdapter { + let registration_id = register_plugin_tracked(Arc::new(NativePluginAdapter { plugin_kind: plugin_kind.clone(), allows_multiple_components: instance.allows_multiple_components, instance: instance.clone(), }))?; activation.plugins.push(instance); - activation.plugin_kinds.push(plugin_kind); + activation + .plugin_registrations + .push((plugin_kind, registration_id)); } Ok(activation) } diff --git a/crates/core/src/plugin/dynamic/worker.rs b/crates/core/src/plugin/dynamic/worker.rs index c4ffefe6a..b6ca57c96 100644 --- a/crates/core/src/plugin/dynamic/worker.rs +++ b/crates/core/src/plugin/dynamic/worker.rs @@ -66,8 +66,8 @@ use crate::api::tool::ToolExecutionInterceptOutcome; use crate::codec::request::AnnotatedLlmRequest; use crate::error::{FlowError, Result as FlowResult}; use crate::plugin::{ - ConfigDiagnostic, DiagnosticLevel, Plugin, PluginError, PluginRegistrationContext, - deregister_plugin, deregister_plugin_checked, register_plugin, + ConfigDiagnostic, DiagnosticLevel, Plugin, PluginDeregistrationOutcome, PluginError, + PluginRegistrationContext, deregister_plugin_registration_checked, register_plugin_tracked, }; use super::{ @@ -122,7 +122,7 @@ pub struct WorkerPluginLoadSpec { /// callbacks cannot outlive the worker activation. pub struct WorkerPluginActivation { plugins: Vec>, - plugin_kinds: Vec, + plugin_registrations: Vec<(String, u64)>, } impl WorkerPluginActivation { @@ -136,16 +136,22 @@ impl WorkerPluginActivation { pub(crate) fn deregister_plugin_kinds_checked(&mut self) -> DynamicPluginTeardownOutcome { let mut outcome = DynamicPluginTeardownOutcome::success(); - let plugin_kinds = std::mem::take(&mut self.plugin_kinds); - for plugin_kind in plugin_kinds.into_iter().rev() { - match deregister_plugin_checked(&plugin_kind) { - Ok(true) => {} - Ok(false) => outcome.record_error( + let plugin_registrations = std::mem::take(&mut self.plugin_registrations); + for (plugin_kind, registration_id) in plugin_registrations.into_iter().rev() { + match deregister_plugin_registration_checked(&plugin_kind, registration_id) { + Ok(PluginDeregistrationOutcome::Removed) => {} + Ok(PluginDeregistrationOutcome::Missing) => outcome.record_error( format!( "worker plugin kind '{plugin_kind}' was not registered during teardown" ), true, ), + Ok(PluginDeregistrationOutcome::Replaced) => outcome.record_error( + format!( + "worker plugin kind '{plugin_kind}' was replaced during teardown and was left registered" + ), + true, + ), Err(error) => outcome.record_error( format!("failed to deregister worker plugin kind '{plugin_kind}': {error}"), false, @@ -166,8 +172,8 @@ impl WorkerPluginActivation { impl Drop for WorkerPluginActivation { fn drop(&mut self) { - for plugin_kind in self.plugin_kinds.iter().rev() { - let _ = deregister_plugin(plugin_kind); + for (plugin_kind, registration_id) in self.plugin_registrations.iter().rev() { + let _ = deregister_plugin_registration_checked(plugin_kind, *registration_id); } } } @@ -182,18 +188,20 @@ where { let mut activation = WorkerPluginActivation { plugins: Vec::new(), - plugin_kinds: Vec::new(), + plugin_registrations: Vec::new(), }; for spec in specs { let instance = load_one_worker_plugin(&spec)?; let plugin_kind = instance.plugin_kind.clone(); - register_plugin(Arc::new(WorkerPluginAdapter { + let registration_id = register_plugin_tracked(Arc::new(WorkerPluginAdapter { plugin_kind: plugin_kind.clone(), allows_multiple_components: instance.allows_multiple_components, instance: instance.clone(), }))?; activation.plugins.push(instance); - activation.plugin_kinds.push(plugin_kind); + activation + .plugin_registrations + .push((plugin_kind, registration_id)); } Ok(activation) } diff --git a/crates/core/src/plugins/model_pricing.rs b/crates/core/src/plugins/model_pricing.rs index ad6b86b05..ae1862a98 100644 --- a/crates/core/src/plugins/model_pricing.rs +++ b/crates/core/src/plugins/model_pricing.rs @@ -14,7 +14,7 @@ use crate::codec::response::{ }; use crate::plugin::{ ConfigDiagnostic, DiagnosticLevel, Plugin, PluginError, PluginRegistration, - PluginRegistrationContext, Result, register_plugin, + PluginRegistrationContext, Result, register_builtin_plugin, }; /// Plugin kind used by the model pricing component. @@ -22,15 +22,7 @@ pub const PRICING_PLUGIN_KIND: &str = "pricing"; /// Registers the built-in model pricing component. pub fn register_pricing_component() -> Result<()> { - match register_plugin(Arc::new(PricingPlugin)) { - Ok(()) => Ok(()), - Err(PluginError::RegistrationFailed(message)) - if message.contains("plugin 'pricing' is already registered") => - { - Ok(()) - } - Err(err) => Err(err), - } + register_builtin_plugin(Arc::new(PricingPlugin)) } struct PricingPlugin; diff --git a/crates/core/src/plugins/nemo_guardrails/component.rs b/crates/core/src/plugins/nemo_guardrails/component.rs index 7ece7fa3d..9e341abf7 100644 --- a/crates/core/src/plugins/nemo_guardrails/component.rs +++ b/crates/core/src/plugins/nemo_guardrails/component.rs @@ -14,7 +14,7 @@ use serde_json::{Map, Value as Json}; use crate::plugin::{ ConfigDiagnostic, ConfigPolicy, DiagnosticLevel, Plugin, PluginComponentSpec, PluginError, PluginRegistrationContext, Result as PluginResult, UnsupportedBehavior, deregister_plugin, - register_plugin, + register_builtin_plugin, }; #[path = "local.rs"] @@ -397,13 +397,7 @@ impl Plugin for NeMoGuardrailsPlugin { /// Registers the `nemo_guardrails` component kind in the plugin registry. pub fn register_nemo_guardrails_component() -> PluginResult<()> { - match register_plugin(Arc::new(NeMoGuardrailsPlugin)) { - Ok(()) => Ok(()), - Err(PluginError::RegistrationFailed(message)) if message.contains("already registered") => { - Ok(()) - } - Err(err) => Err(err), - } + register_builtin_plugin(Arc::new(NeMoGuardrailsPlugin)) } /// Deregisters the `nemo_guardrails` component kind from the plugin registry. diff --git a/crates/core/tests/integration/native_plugin_tests.rs b/crates/core/tests/integration/native_plugin_tests.rs index 3451d4955..34fbc9b57 100644 --- a/crates/core/tests/integration/native_plugin_tests.rs +++ b/crates/core/tests/integration/native_plugin_tests.rs @@ -28,8 +28,9 @@ use nemo_relay::plugin::dynamic::{ load_native_plugins, }; use nemo_relay::plugin::{ - PluginComponentSpec, PluginConfig, clear_plugin_configuration, deregister_plugin, - initialize_plugins_exact, list_plugin_kinds, + ConfigDiagnostic, Plugin, PluginComponentSpec, PluginConfig, PluginRegistrationContext, + Result as PluginResult, clear_plugin_configuration, deregister_plugin, + initialize_plugins_exact, list_plugin_kinds, lookup_plugin, register_plugin, }; use serde_json::{Map, Value as Json, json}; use sha2::{Digest, Sha256}; @@ -39,6 +40,34 @@ use uuid::Uuid; static NATIVE_PLUGIN_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +struct ReplacementRegistryPlugin; + +impl Plugin for ReplacementRegistryPlugin { + fn plugin_kind(&self) -> &str { + "fixture_native" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + Vec::new() + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + _ctx: &'a mut PluginRegistrationContext, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async { Ok(()) }) + } +} + +struct FixtureNativeRegistrationCleanup; + +impl Drop for FixtureNativeRegistrationCleanup { + fn drop(&mut self) { + let _ = deregister_plugin("fixture_native"); + } +} + struct ThreadScopeStackRestore(Option); impl ThreadScopeStackRestore { @@ -1144,6 +1173,44 @@ async fn plugin_host_clear_surfaces_missing_kind_and_releases_safe_owner() { activation.clear().expect("recovered host should clear"); } +#[tokio::test] +async fn plugin_host_clear_preserves_a_replacement_registration() { + let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; + let fixture = build_fixture_plugin(); + let manifest_ref = write_manifest(&fixture); + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("plugin host should activate"); + + assert!(deregister_plugin("fixture_native")); + let replacement: Arc = Arc::new(ReplacementRegistryPlugin); + register_plugin(Arc::clone(&replacement)).expect("replacement plugin should register"); + let _cleanup = FixtureNativeRegistrationCleanup; + + let error = activation + .clear() + .expect_err("replacement during teardown should be surfaced") + .to_string(); + assert!(error.contains("fixture_native"), "{error}"); + assert!(error.contains("was replaced"), "{error}"); + assert!(error.contains("left registered"), "{error}"); + + let registered = lookup_plugin("fixture_native").expect("replacement must remain registered"); + assert!(Arc::ptr_eq(®istered, &replacement)); + assert!(deregister_plugin("fixture_native")); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + [host_spec("fixture_native", &manifest_ref)], + ) + .await + .expect("safe replacement detection should release the host owner"); + activation.clear().expect("recovered host should clear"); +} + #[tokio::test] async fn plugin_host_partial_load_failure_rolls_back_and_releases_owner() { let _guard = NATIVE_PLUGIN_TEST_LOCK.lock().await; diff --git a/crates/core/tests/integration/plugin_host_builtin_ownership_tests.rs b/crates/core/tests/integration/plugin_host_builtin_ownership_tests.rs new file mode 100644 index 000000000..1d60e0e30 --- /dev/null +++ b/crates/core/tests/integration/plugin_host_builtin_ownership_tests.rs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Isolated regression coverage for builtin plugin ownership. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use nemo_relay::plugin::dynamic::{DynamicPluginActivationSpec, PluginHostActivation}; +use nemo_relay::plugin::{ + ConfigDiagnostic, Plugin, PluginConfig, PluginRegistrationContext, Result, deregister_plugin, + register_plugin, +}; +use serde_json::{Map, Value as Json}; + +struct PreclaimedObservabilityPlugin; + +impl Plugin for PreclaimedObservabilityPlugin { + fn plugin_kind(&self) -> &str { + "observability" + } + + fn validate(&self, _plugin_config: &Map) -> Vec { + Vec::new() + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + _ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Ok(()) }) + } +} + +#[tokio::test] +async fn host_rejects_a_builtin_kind_preclaimed_before_first_ensure() { + register_plugin(Arc::new(PreclaimedObservabilityPlugin)) + .expect("the fixture must preclaim the builtin kind before first ensure"); + + let error = match PluginHostActivation::activate( + PluginConfig::default(), + Vec::::new(), + ) + .await + { + Ok((activation, _)) => { + activation + .clear() + .expect("unexpected host activation should clear"); + panic!("a preclaimed builtin kind must prevent host activation"); + } + Err(error) => error.to_string(), + }; + + assert!( + error.contains("reserved builtin plugin 'observability'"), + "{error}" + ); + assert!(error.contains("already registered"), "{error}"); + assert!(deregister_plugin("observability")); + + let (activation, _) = PluginHostActivation::activate( + PluginConfig::default(), + Vec::::new(), + ) + .await + .expect("host activation should recover after the conflicting registration is removed"); + activation + .clear() + .expect("recovered host activation should clear"); +} From b5d3bf89f1f7d107fd988f209130e1a55e5615d7 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 7 Jul 2026 08:16:52 -0600 Subject: [PATCH 04/10] test(plugin): wait reliably for cancelled activation Signed-off-by: Bryan Bednarski --- crates/core/tests/unit/plugin_tests.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index aceb7fa67..79569e668 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -1130,18 +1130,21 @@ fn test_initialize_plugins_transaction_finishes_after_caller_cancellation() { release.notify_one(); registered.notified().await; - for _ in 0..100 { - if active_plugin_report().is_some_and(|report| { - report - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "blocking.warning") - }) { - return; + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if active_plugin_report().is_some_and(|report| { + report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "blocking.warning") + }) { + break; + } + tokio::task::yield_now().await; } - tokio::task::yield_now().await; - } - panic!("owned initialization transaction did not finish after caller cancellation"); + }) + .await + .expect("owned initialization transaction did not finish after caller cancellation"); }); reset_global(); From 28d4c0961bdee3fe3d5f562ac9918c1f7b4f5e54 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Mon, 6 Jul 2026 14:29:30 -0600 Subject: [PATCH 05/10] feat(node): activate dynamic plugins Signed-off-by: Bryan Bednarski --- crates/node/plugin.d.ts | 37 +++ crates/node/plugin.js | 14 + crates/node/src/api/mod.rs | 136 +++++++++ crates/node/tests/dynamic_plugin_tests.mjs | 323 +++++++++++++++++++++ 4 files changed, 510 insertions(+) create mode 100644 crates/node/tests/dynamic_plugin_tests.mjs diff --git a/crates/node/plugin.d.ts b/crates/node/plugin.d.ts index 7d8ac88d6..dff83903a 100644 --- a/crates/node/plugin.d.ts +++ b/crates/node/plugin.d.ts @@ -45,6 +45,28 @@ export interface PluginConfig { policy?: ConfigPolicy; } +/** Execution lane for a dynamically loaded Relay plugin. */ +export type DynamicPluginKind = 'rust_dynamic' | 'worker'; + +/** Explicitly resolved dynamic plugin load and component configuration. */ +export interface DynamicPluginActivationSpec { + pluginId: string; + kind: DynamicPluginKind; + manifestRef: string; + environmentRef?: string | null; + config?: Record; +} + +/** Owns one process-wide dynamic plugin host activation. */ +export interface DynamicPluginActivation { + /** Validation report produced by the successful activation. */ + readonly report: ConfigReport; + /** Whether this object still owns the dynamic plugin host. */ + readonly active: boolean; + /** Clear callbacks before unloading libraries and workers. Idempotent. */ + close(): Promise; +} + /** A mark Relay materializes under a managed lifecycle. */ export interface PendingMarkSpec { name: string; @@ -213,6 +235,21 @@ export declare function validate(config: PluginConfig): ConfigReport; * the promise rejects with the underlying validation or setup error. */ export declare function initialize(config: PluginConfig): Promise; +/** + * Load and activate explicitly resolved dynamic plugins. + * + * The returned object owns loaded libraries and worker processes. Keep it + * alive while plugin callbacks may run and call `close()` for deterministic + * teardown. Garbage collection is a defensive fallback only. + * + * @param config - Base plugin configuration activated alongside dynamic components. + * @param specs - Explicit manifest and component configuration for each plugin. + * @returns The owned activation and its validation report. + */ +export declare function activateDynamicPlugins( + config: PluginConfig, + specs: DynamicPluginActivationSpec[], +): Promise; /** * Clear the active plugin configuration. * diff --git a/crates/node/plugin.js b/crates/node/plugin.js index a84c3212b..7d1cfbeae 100644 --- a/crates/node/plugin.js +++ b/crates/node/plugin.js @@ -78,6 +78,19 @@ function initialize(config) { return lib.initializePlugins(config); } +/** + * Load and activate explicitly resolved dynamic plugins. + * + * @param {object} config - Base plugin configuration document. + * @param {Array} specs - Native-library or worker plugin load specifications. + * @returns {Promise} An owned activation with `report`, `active`, and `close()`. + * @remarks Keep the returned activation alive while its callbacks may run and + * call `close()` for deterministic teardown. + */ +function activateDynamicPlugins(config, specs) { + return lib.activateDynamicPlugins(config, specs); +} + /** * Clear the active plugin configuration. * @@ -161,6 +174,7 @@ module.exports = { ComponentSpec, validate, initialize, + activateDynamicPlugins, clear, report, listKinds, diff --git a/crates/node/src/api/mod.rs b/crates/node/src/api/mod.rs index efd757fd7..c66a559ba 100644 --- a/crates/node/src/api/mod.rs +++ b/crates/node/src/api/mod.rs @@ -52,6 +52,10 @@ use nemo_relay::plugin::{ list_plugin_kinds as list_plugin_kinds_impl, register_plugin as register_plugin_impl, validate_plugin_config as validate_plugin_config_impl, }; +use nemo_relay::plugin::dynamic::{ + DynamicPluginActivationSpec as CoreDynamicPluginActivationSpec, DynamicPluginKind, + PluginHostActivation as CorePluginHostActivation, +}; use nemo_relay::shared_runtime::initialize_shared_runtime_binding; use nemo_relay_adaptive::acg::{ AgentIdentity, CacheRequestFacts, CacheTelemetryEvent, CacheTelemetryProvider, @@ -3744,6 +3748,138 @@ pub async fn initialize_plugins(config: Json) -> napi::Result { serde_json::to_value(&report).map_err(|e| napi::Error::from_reason(e.to_string())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct NodeDynamicPluginActivationSpec { + #[serde(alias = "plugin_id")] + plugin_id: String, + kind: DynamicPluginKind, + #[serde(alias = "manifest_ref")] + manifest_ref: String, + #[serde(default, alias = "environment_ref")] + environment_ref: Option, + #[serde(default)] + config: serde_json::Map, +} + +impl From for CoreDynamicPluginActivationSpec { + fn from(spec: NodeDynamicPluginActivationSpec) -> Self { + Self { + plugin_id: spec.plugin_id, + kind: spec.kind, + manifest_ref: spec.manifest_ref, + environment_ref: spec.environment_ref, + config: spec.config, + } + } +} + +/// Owned dynamic plugin activation. +/// +/// Keep this object alive while code may invoke callbacks registered by the +/// dynamic plugins. Call `close()` for deterministic cleanup; garbage +/// collection performs the same cleanup as a defensive fallback. +#[napi] +pub struct DynamicPluginActivation { + inner: Arc>>, + report: Json, +} + +#[napi] +impl DynamicPluginActivation { + /// Return the validation report produced by activation. + #[napi(getter)] + pub fn report(&self) -> Json { + self.report.clone() + } + + /// Return whether this object still owns the dynamic plugin host. + #[napi(getter)] + pub fn active(&self) -> napi::Result { + self.inner + .lock() + .map(|activation| activation.is_some()) + .map_err(|error| { + napi::Error::from_reason(format!( + "dynamic plugin activation lock poisoned: {error}" + )) + }) + } + + /// Clear plugin callbacks before unloading libraries and workers. + /// + /// This method is idempotent, including when concurrent callers race to + /// close the same activation. + #[napi(ts_return_type = "Promise")] + pub fn close(&self, env: Env) -> napi::Result { + let inner = Arc::clone(&self.inner); + env.execute_tokio_future( + async move { + let activation = inner + .lock() + .map_err(|error| { + napi::Error::from_reason(format!( + "dynamic plugin activation lock poisoned: {error}" + )) + })? + .take(); + if let Some(activation) = activation { + tokio::task::spawn_blocking(move || activation.clear()) + .await + .map_err(|error| { + napi::Error::from_reason(format!( + "dynamic plugin teardown task failed: {error}" + )) + })? + .map_err(|error| napi::Error::from_reason(error.to_string()))?; + } + Ok(()) + }, + |env, _| env.get_undefined(), + ) + } +} + +impl Drop for DynamicPluginActivation { + fn drop(&mut self) { + let activation = match self.inner.lock() { + Ok(mut activation) => activation.take(), + Err(poisoned) => poisoned.into_inner().take(), + }; + if let Some(activation) = activation + && let Err(error) = activation.clear() + { + eprintln!("nemo_relay: dynamic plugin finalizer teardown failed: {error}"); + } + } +} + +/// Load and activate explicitly resolved dynamic plugins. +/// +/// The returned object owns all loaded libraries and worker processes. Its +/// validation report is available through the `report` property. +#[napi] +pub async fn activate_dynamic_plugins( + config: Json, + specs: Json, +) -> napi::Result { + let config: PluginConfig = serde_json::from_value(config) + .map_err(|error| napi::Error::from_reason(format!("invalid plugin config: {error}")))?; + let specs: Vec = serde_json::from_value(specs).map_err(|error| { + napi::Error::from_reason(format!("invalid dynamic plugin specs: {error}")) + })?; + let specs = specs.into_iter().map(Into::into).collect::>(); + let (activation, report) = CorePluginHostActivation::activate(config, specs) + .await + .map_err(|error| napi::Error::from_reason(error.to_string()))?; + let report = serde_json::to_value(report) + .map_err(|error| napi::Error::from_reason(error.to_string()))?; + Ok(DynamicPluginActivation { + inner: Arc::new(StdMutex::new(Some(activation))), + report, + }) +} + /// Clear the active global plugin configuration. #[napi] pub fn clear_plugin_configuration() -> napi::Result<()> { diff --git a/crates/node/tests/dynamic_plugin_tests.mjs b/crates/node/tests/dynamic_plugin_tests.mjs new file mode 100644 index 000000000..ecbbce066 --- /dev/null +++ b/crates/node/tests/dynamic_plugin_tests.mjs @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const lib = require('../index.js'); +const plugin = require('../plugin.js'); + +const nodeDir = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = path.resolve(nodeDir, '../..'); +const fixtureTarget = path.join(repoRoot, 'target', 'node-dynamic-plugin-fixtures'); +const tempRoot = mkdtempSync(path.join(tmpdir(), 'nemo-relay-node-dynamic-')); +const relayVersion = JSON.parse(readFileSync(path.join(nodeDir, 'package.json'), 'utf8')).version; + +let nativeManifestRef; +let workerManifestRef; + +function tomlString(value) { + return JSON.stringify(value); +} + +function nativeLibraryName() { + if (process.platform === 'win32') { + return 'nemo_relay_plugin_fixture.dll'; + } + if (process.platform === 'darwin') { + return 'libnemo_relay_plugin_fixture.dylib'; + } + return 'libnemo_relay_plugin_fixture.so'; +} + +function workerBinaryName() { + return process.platform === 'win32' ? 'nemo-relay-worker-plugin-fixture.exe' : 'nemo-relay-worker-plugin-fixture'; +} + +function buildFixture(manifestPath) { + execFileSync( + process.env.CARGO || 'cargo', + ['build', '--quiet', '--manifest-path', manifestPath, '--target-dir', fixtureTarget], + { stdio: 'inherit' }, + ); +} + +function buildNativeFixture(sourceManifestPath) { + const sourceDirectory = path.dirname(sourceManifestPath); + const fixtureDirectory = path.join(tempRoot, 'native-source'); + const fixtureSourceDirectory = path.join(fixtureDirectory, 'src'); + mkdirSync(fixtureSourceDirectory, { recursive: true }); + const pluginCrate = path.join(repoRoot, 'crates', 'plugin'); + const manifest = readFileSync(sourceManifestPath, 'utf8').replace( + 'nemo-relay-plugin = { path = "../../../../plugin" }', + `nemo-relay-plugin = { path = ${tomlString(pluginCrate)} }`, + ); + const fixtureManifest = path.join(fixtureDirectory, 'Cargo.toml'); + writeFileSync(fixtureManifest, manifest); + writeFileSync(path.join(fixtureSourceDirectory, 'lib.rs'), readFileSync(path.join(sourceDirectory, 'src', 'lib.rs'))); + buildFixture(fixtureManifest); +} + +function writeNativeManifest(libraryPath) { + const directory = path.join(tempRoot, 'native'); + mkdirSync(directory, { recursive: true }); + const manifestRef = path.join(directory, 'relay-plugin.toml'); + writeFileSync( + manifestRef, + `manifest_version = 1 + +[plugin] +id = "fixture_native" +kind = "rust_dynamic" + +[compat] +relay = ${tomlString(`=${relayVersion}`)} +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native"] + +[load] +library = ${tomlString(libraryPath)} +symbol = "nemo_relay_fixture_native_plugin" +`, + ); + return manifestRef; +} + +function writeWorkerManifest(entrypoint) { + const directory = path.join(tempRoot, 'worker'); + mkdirSync(directory, { recursive: true }); + const manifestRef = path.join(directory, 'relay-plugin.toml'); + writeFileSync( + manifestRef, + `manifest_version = 1 + +[plugin] +id = "fixture_worker" +kind = "worker" + +[compat] +relay = ${tomlString(`=${relayVersion}`)} +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[load] +runtime = "rust" +entrypoint = ${tomlString(entrypoint)} +`, + ); + return manifestRef; +} + +function activationSpec(pluginId, kind, manifestRef, config = {}) { + return { + pluginId, + kind, + manifestRef, + config, + }; +} + +function nativeRequest(model = 'fixture-model') { + return { + headers: {}, + content: { + model, + messages: [], + }, + }; +} + +async function executeTool(name) { + return lib.toolCallExecute( + name, + { original: true }, + (args) => ({ ...args, downstream: true }), + null, + null, + null, + null, + ); +} + +async function executeLlm(name) { + return lib.llmCallExecute( + name, + nativeRequest(), + (request) => ({ + downstream: true, + requestContent: request.content, + }), + null, + null, + null, + null, + null, + ); +} + +before(() => { + const nativeFixture = path.join(repoRoot, 'crates', 'core', 'tests', 'fixtures', 'native_plugin', 'Cargo.toml'); + const workerFixture = path.join(repoRoot, 'crates', 'core', 'tests', 'fixtures', 'worker_plugin', 'Cargo.toml'); + buildNativeFixture(nativeFixture); + buildFixture(workerFixture); + nativeManifestRef = writeNativeManifest(path.join(fixtureTarget, 'debug', nativeLibraryName())); + workerManifestRef = writeWorkerManifest(path.join(fixtureTarget, 'debug', workerBinaryName())); +}); + +after(() => { + rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('dynamic plugin host', () => { + it('owns native managed callbacks until idempotent close', async () => { + const activation = await plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + ]); + try { + assert.deepEqual(activation.report.diagnostics, []); + assert.equal(activation.active, true); + assert.throws(() => plugin.clear(), /active dynamic plugin host/i); + + const toolResult = await executeTool('node_native_dynamic_tool'); + assert.equal(toolResult.downstream, true); + assert.equal(toolResult.native_plugin_tool_execution_request, true); + assert.equal(toolResult.native_plugin_tool_execution, true); + + const llmResult = await executeLlm('node_native_dynamic_llm'); + assert.equal(llmResult.downstream, true); + assert.equal(llmResult.requestContent.native_plugin_llm_execution_request, true); + assert.equal(llmResult.native_plugin_llm_execution, true); + + await Promise.all([activation.close(), activation.close()]); + assert.equal(activation.active, false); + await activation.close(); + + const toolAfterClose = await executeTool('node_native_closed_tool'); + assert.deepEqual(toolAfterClose, { original: true, downstream: true }); + const llmAfterClose = await executeLlm('node_native_closed_llm'); + assert.equal(llmAfterClose.downstream, true); + assert.equal(llmAfterClose.requestContent.native_plugin_llm_execution_request, undefined); + assert.equal(llmAfterClose.native_plugin_llm_execution, undefined); + } finally { + await activation.close(); + } + }); + + it('owns worker managed callbacks until close', async () => { + const activation = await plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_worker', 'worker', workerManifestRef), + ]); + try { + assert.deepEqual(activation.report.diagnostics, []); + const toolResult = await executeTool('node_worker_dynamic_tool'); + assert.equal(toolResult.worker_plugin_tool_execution_request, true); + assert.equal(toolResult.worker_plugin_tool_execution, true); + + const llmResult = await executeLlm('node_worker_dynamic_llm'); + assert.equal(llmResult.requestContent.worker_plugin_llm_execution_request, true); + assert.equal(llmResult.worker_plugin_llm_execution, true); + } finally { + await activation.close(); + } + + const toolAfterClose = await executeTool('node_worker_closed_tool'); + assert.deepEqual(toolAfterClose, { original: true, downstream: true }); + const llmAfterClose = await executeLlm('node_worker_closed_llm'); + assert.equal(llmAfterClose.requestContent.worker_plugin_llm_execution_request, undefined); + assert.equal(llmAfterClose.worker_plugin_llm_execution, undefined); + }); + + it('preserves manifest and validation diagnostics in rejected promises', async () => { + const missingManifest = path.join(tempRoot, 'missing', 'relay-plugin.toml'); + await assert.rejects( + () => + plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + activationSpec('missing_native', 'rust_dynamic', missingManifest), + ]), + (error) => { + assert.match(error.message, /native plugin load failed/i); + assert.match(error.message, /relay-plugin\.toml/); + assert.match(error.message, /does not exist/i); + return true; + }, + ); + + await assert.rejects( + () => + plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef, { reject: true }), + ]), + /fixture rejection requested/i, + ); + + const recovered = await plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef), + ]); + await recovered.close(); + }); + + it('defensively clears a native activation during garbage collection', () => { + const pluginModule = path.join(nodeDir, 'plugin.js'); + const script = ` + import { createRequire } from 'node:module'; + const require = createRequire(${JSON.stringify(path.join(nodeDir, 'package.json'))}); + const plugin = require(${JSON.stringify(pluginModule)}); + const config = { version: 1, components: [] }; + const specs = [${JSON.stringify(activationSpec('fixture_native', 'rust_dynamic', nativeManifestRef))}]; + let activation = await plugin.activateDynamicPlugins(config, specs); + const weak = new WeakRef(activation); + activation = null; + let collected = false; + for (let index = 0; index < 100; index += 1) { + global.gc(); + await new Promise((resolve) => setImmediate(resolve)); + if (weak.deref() === undefined) { + collected = true; + break; + } + await new Promise((resolve) => setImmediate(resolve)); + } + if (!collected) { + throw new Error('dynamic activation was not garbage collected'); + } + let replacement; + let lastError; + for (let index = 0; index < 100; index += 1) { + global.gc(); + await new Promise((resolve) => setImmediate(resolve)); + try { + replacement = await plugin.activateDynamicPlugins(config, specs); + break; + } catch (error) { + lastError = error; + } + } + if (!replacement) { + throw lastError ?? new Error('dynamic activation finalizer did not release ownership'); + } + await replacement.close(); + `; + execFileSync(process.execPath, ['--expose-gc', '--input-type=module', '--eval', script], { + stdio: 'inherit', + timeout: 30_000, + }); + }); +}); From bce713bd7d0a7a7600caf3c98be019c9decd72cf Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Mon, 6 Jul 2026 18:13:39 -0600 Subject: [PATCH 06/10] Fix Node dynamic plugin teardown lifecycle Signed-off-by: Bryan Bednarski --- crates/node/src/api/mod.rs | 185 ++++++++++++++++----- crates/node/tests/dynamic_plugin_tests.mjs | 117 ++++++++++++- 2 files changed, 254 insertions(+), 48 deletions(-) diff --git a/crates/node/src/api/mod.rs b/crates/node/src/api/mod.rs index c66a559ba..e7272c3e7 100644 --- a/crates/node/src/api/mod.rs +++ b/crates/node/src/api/mod.rs @@ -44,6 +44,10 @@ use nemo_relay::api::tool::ToolAttributes; use nemo_relay::codec::request::AnnotatedLlmRequest; use nemo_relay::codec::response::Usage; use nemo_relay::error::{FlowError, Result as FlowResult}; +use nemo_relay::plugin::dynamic::{ + DynamicPluginActivationSpec as CoreDynamicPluginActivationSpec, DynamicPluginKind, + PluginHostActivation as CorePluginHostActivation, +}; use nemo_relay::plugin::{ ConfigDiagnostic, DiagnosticLevel, Plugin, PluginConfig, PluginError, PluginRegistration, PluginRegistrationContext, active_plugin_report as active_plugin_report_impl, @@ -52,10 +56,6 @@ use nemo_relay::plugin::{ list_plugin_kinds as list_plugin_kinds_impl, register_plugin as register_plugin_impl, validate_plugin_config as validate_plugin_config_impl, }; -use nemo_relay::plugin::dynamic::{ - DynamicPluginActivationSpec as CoreDynamicPluginActivationSpec, DynamicPluginKind, - PluginHostActivation as CorePluginHostActivation, -}; use nemo_relay::shared_runtime::initialize_shared_runtime_binding; use nemo_relay_adaptive::acg::{ AgentIdentity, CacheRequestFacts, CacheTelemetryEvent, CacheTelemetryProvider, @@ -3781,10 +3781,131 @@ impl From for CoreDynamicPluginActivationSpec { /// collection performs the same cleanup as a defensive fallback. #[napi] pub struct DynamicPluginActivation { - inner: Arc>>, + close_state: Arc, report: Json, } +type DynamicPluginTeardownResult = std::result::Result<(), String>; + +enum DynamicPluginCloseStatus { + Active(Option), + Closing, + Closed, +} + +struct DynamicPluginCloseState { + status: StdMutex, + completion: tokio::sync::watch::Sender>, +} + +impl DynamicPluginCloseState { + fn new(activation: CorePluginHostActivation) -> Self { + let (completion, _) = tokio::sync::watch::channel(None); + Self { + status: StdMutex::new(DynamicPluginCloseStatus::Active(Some(activation))), + completion, + } + } + + fn active(&self) -> bool { + let status = self + .status + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match &*status { + DynamicPluginCloseStatus::Active(activation) => activation.is_some(), + DynamicPluginCloseStatus::Closing | DynamicPluginCloseStatus::Closed => false, + } + } + + fn begin_close(self: &Arc, log_finalizer_error: bool) { + let activation = { + let mut status = self + .status + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match &mut *status { + DynamicPluginCloseStatus::Active(activation) => { + let activation = activation.take(); + *status = DynamicPluginCloseStatus::Closing; + activation + } + DynamicPluginCloseStatus::Closing | DynamicPluginCloseStatus::Closed => None, + } + }; + let Some(activation) = activation else { + return; + }; + + // Keep the activation outside the spawned closure so a thread-spawn + // failure cannot drop it and synchronously run teardown on the JS thread. + let activation = Arc::new(StdMutex::new(Some(activation))); + let worker_activation = Arc::clone(&activation); + let close_state = Arc::clone(self); + let spawn = std::thread::Builder::new() + .name("nemo-relay-node-plugin-teardown".into()) + .spawn(move || { + let activation = worker_activation + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + let result = match activation { + Some(activation) => { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + activation.clear() + })) + .map_err(|_| "dynamic plugin teardown task panicked".to_string()) + .and_then(|result| result.map_err(|error| error.to_string())) + } + None => Err("dynamic plugin teardown task lost its activation".to_string()), + }; + if log_finalizer_error && let Err(error) = &result { + eprintln!("nemo_relay: dynamic plugin finalizer teardown failed: {error}"); + } + close_state.finish(result); + }); + + if let Err(error) = spawn { + // Cleanup must never fall back to the JS thread. Retain the + // activation for process lifetime if no teardown thread can start. + if let Some(activation) = activation + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + std::mem::forget(activation); + } + let error = format!("failed to start dynamic plugin teardown task: {error}"); + if log_finalizer_error { + eprintln!("nemo_relay: dynamic plugin finalizer teardown failed: {error}"); + } + self.finish(Err(error)); + } + } + + fn finish(&self, result: DynamicPluginTeardownResult) { + *self + .status + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = DynamicPluginCloseStatus::Closed; + self.completion.send_replace(Some(result)); + } + + async fn wait_for_close(&self) -> DynamicPluginTeardownResult { + let mut completion = self.completion.subscribe(); + loop { + if let Some(result) = completion.borrow().clone() { + return result; + } + if completion.changed().await.is_err() { + return Err( + "dynamic plugin teardown result channel closed unexpectedly".to_string() + ); + } + } + } +} + #[napi] impl DynamicPluginActivation { /// Return the validation report produced by activation. @@ -3796,14 +3917,7 @@ impl DynamicPluginActivation { /// Return whether this object still owns the dynamic plugin host. #[napi(getter)] pub fn active(&self) -> napi::Result { - self.inner - .lock() - .map(|activation| activation.is_some()) - .map_err(|error| { - napi::Error::from_reason(format!( - "dynamic plugin activation lock poisoned: {error}" - )) - }) + Ok(self.close_state.active()) } /// Clear plugin callbacks before unloading libraries and workers. @@ -3812,28 +3926,14 @@ impl DynamicPluginActivation { /// close the same activation. #[napi(ts_return_type = "Promise")] pub fn close(&self, env: Env) -> napi::Result { - let inner = Arc::clone(&self.inner); + let close_state = Arc::clone(&self.close_state); + close_state.begin_close(false); env.execute_tokio_future( async move { - let activation = inner - .lock() - .map_err(|error| { - napi::Error::from_reason(format!( - "dynamic plugin activation lock poisoned: {error}" - )) - })? - .take(); - if let Some(activation) = activation { - tokio::task::spawn_blocking(move || activation.clear()) - .await - .map_err(|error| { - napi::Error::from_reason(format!( - "dynamic plugin teardown task failed: {error}" - )) - })? - .map_err(|error| napi::Error::from_reason(error.to_string()))?; - } - Ok(()) + close_state + .wait_for_close() + .await + .map_err(napi::Error::from_reason) }, |env, _| env.get_undefined(), ) @@ -3842,15 +3942,7 @@ impl DynamicPluginActivation { impl Drop for DynamicPluginActivation { fn drop(&mut self) { - let activation = match self.inner.lock() { - Ok(mut activation) => activation.take(), - Err(poisoned) => poisoned.into_inner().take(), - }; - if let Some(activation) = activation - && let Err(error) = activation.clear() - { - eprintln!("nemo_relay: dynamic plugin finalizer teardown failed: {error}"); - } + self.close_state.begin_close(true); } } @@ -3865,9 +3957,10 @@ pub async fn activate_dynamic_plugins( ) -> napi::Result { let config: PluginConfig = serde_json::from_value(config) .map_err(|error| napi::Error::from_reason(format!("invalid plugin config: {error}")))?; - let specs: Vec = serde_json::from_value(specs).map_err(|error| { - napi::Error::from_reason(format!("invalid dynamic plugin specs: {error}")) - })?; + let specs: Vec = + serde_json::from_value(specs).map_err(|error| { + napi::Error::from_reason(format!("invalid dynamic plugin specs: {error}")) + })?; let specs = specs.into_iter().map(Into::into).collect::>(); let (activation, report) = CorePluginHostActivation::activate(config, specs) .await @@ -3875,7 +3968,7 @@ pub async fn activate_dynamic_plugins( let report = serde_json::to_value(report) .map_err(|error| napi::Error::from_reason(error.to_string()))?; Ok(DynamicPluginActivation { - inner: Arc::new(StdMutex::new(Some(activation))), + close_state: Arc::new(DynamicPluginCloseState::new(activation)), report, }) } diff --git a/crates/node/tests/dynamic_plugin_tests.mjs b/crates/node/tests/dynamic_plugin_tests.mjs index ecbbce066..b8797a608 100644 --- a/crates/node/tests/dynamic_plugin_tests.mjs +++ b/crates/node/tests/dynamic_plugin_tests.mjs @@ -95,8 +95,8 @@ symbol = "nemo_relay_fixture_native_plugin" return manifestRef; } -function writeWorkerManifest(entrypoint) { - const directory = path.join(tempRoot, 'worker'); +function writeWorkerManifest(entrypoint, name = 'worker') { + const directory = path.join(tempRoot, name); mkdirSync(directory, { recursive: true }); const manifestRef = path.join(directory, 'relay-plugin.toml'); writeFileSync( @@ -125,6 +125,14 @@ entrypoint = ${tomlString(entrypoint)} return manifestRef; } +function writeWorkerWrapper(entrypoint, pidFile, name) { + const wrapper = path.join(tempRoot, `${name}.sh`); + writeFileSync(wrapper, `#!/bin/sh\nprintf '%s' "$$" > ${tomlString(pidFile)}\nexec ${tomlString(entrypoint)}\n`, { + mode: 0o755, + }); + return wrapper; +} + function activationSpec(pluginId, kind, manifestRef, config = {}) { return { pluginId, @@ -244,6 +252,46 @@ describe('dynamic plugin host', () => { assert.equal(llmAfterClose.worker_plugin_llm_execution, undefined); }); + it( + 'keeps every concurrent close pending until the shared teardown completes', + { skip: process.platform === 'win32' }, + async () => { + const workerBinary = path.join(fixtureTarget, 'debug', workerBinaryName()); + const pidFile = path.join(tempRoot, 'concurrent-close-worker.pid'); + const wrapper = writeWorkerWrapper(workerBinary, pidFile, 'concurrent-close-worker'); + const manifestRef = writeWorkerManifest(wrapper, 'concurrent-close-worker'); + const activation = await plugin.activateDynamicPlugins({ version: 1, components: [] }, [ + activationSpec('fixture_worker', 'worker', manifestRef), + ]); + const workerPid = Number(readFileSync(pidFile, 'utf8')); + process.kill(workerPid, 'SIGSTOP'); + + const firstClose = activation.close(); + const secondClose = activation.close(); + let earlyResult; + try { + earlyResult = await Promise.race([ + firstClose.then(() => 'first'), + secondClose.then(() => 'second'), + new Promise((resolve) => setTimeout(() => resolve('pending'), 200)), + ]); + } finally { + try { + process.kill(workerPid, 'SIGCONT'); + } catch (error) { + if (error.code !== 'ESRCH') { + throw error; + } + } + await Promise.all([firstClose, secondClose]); + } + + assert.equal(earlyResult, 'pending'); + assert.equal(activation.active, false); + await activation.close(); + }, + ); + it('preserves manifest and validation diagnostics in rejected promises', async () => { const missingManifest = path.join(tempRoot, 'missing', 'relay-plugin.toml'); await assert.rejects( @@ -320,4 +368,69 @@ describe('dynamic plugin host', () => { timeout: 30_000, }); }); + + it( + 'never waits for worker teardown on the JavaScript thread during garbage collection', + { skip: process.platform === 'win32' }, + () => { + const workerBinary = path.join(fixtureTarget, 'debug', workerBinaryName()); + const pidFile = path.join(tempRoot, 'finalizer-worker.pid'); + const wrapper = writeWorkerWrapper(workerBinary, pidFile, 'finalizer-worker'); + const manifestRef = writeWorkerManifest(wrapper, 'finalizer-worker'); + const pluginModule = path.join(nodeDir, 'plugin.js'); + const script = ` + import { spawn } from 'node:child_process'; + import { readFileSync } from 'node:fs'; + import { performance } from 'node:perf_hooks'; + import { createRequire } from 'node:module'; + const require = createRequire(${JSON.stringify(path.join(nodeDir, 'package.json'))}); + const plugin = require(${JSON.stringify(pluginModule)}); + const config = { version: 1, components: [] }; + const specs = [${JSON.stringify(activationSpec('fixture_worker', 'worker', manifestRef))}]; + let activation = await plugin.activateDynamicPlugins(config, specs); + const weak = new WeakRef(activation); + activation = null; + await new Promise((resolve) => setImmediate(resolve)); + + const workerPid = Number(readFileSync(${JSON.stringify(pidFile)}, 'utf8')); + process.kill(workerPid, 'SIGSTOP'); + const resumer = spawn( + '/bin/sh', + ['-c', 'sleep 0.8; kill -CONT "$1"', 'resume-worker', String(workerPid)], + { detached: true, stdio: 'ignore' }, + ); + resumer.unref(); + + const startedAt = performance.now(); + global.gc(); + const elapsed = performance.now() - startedAt; + if (weak.deref() !== undefined) { + throw new Error('dynamic activation was not garbage collected'); + } + if (elapsed >= 400) { + throw new Error(\`dynamic activation finalizer blocked the JavaScript thread for \${elapsed}ms\`); + } + + let replacement; + let lastError; + for (let index = 0; index < 500; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + try { + replacement = await plugin.activateDynamicPlugins(config, specs); + break; + } catch (error) { + lastError = error; + } + } + if (!replacement) { + throw lastError ?? new Error('dynamic activation finalizer did not release ownership'); + } + await replacement.close(); + `; + execFileSync(process.execPath, ['--expose-gc', '--input-type=module', '--eval', script], { + stdio: 'inherit', + timeout: 30_000, + }); + }, + ); }); From 64f89232fae7048a1ac1e0dc5e560b9daaae8dde Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Mon, 6 Jul 2026 16:59:42 -0600 Subject: [PATCH 07/10] feat(cli): launch OpenClaw through Relay Signed-off-by: Bryan Bednarski --- Cargo.lock | 61 + README.md | 21 +- crates/cli/Cargo.toml | 2 + crates/cli/README.md | 14 +- crates/cli/src/config.rs | 25 +- crates/cli/src/doctor.rs | 31 + crates/cli/src/installer.rs | 8 + crates/cli/src/launcher.rs | 186 ++- crates/cli/src/main.rs | 4 + crates/cli/src/openclaw.rs | 1173 +++++++++++++++++++ crates/cli/src/setup.rs | 1 + crates/cli/src/setup/model.rs | 3 + crates/cli/tests/coverage/config_tests.rs | 12 + crates/cli/tests/coverage/launcher_tests.rs | 179 +++ crates/cli/tests/coverage/openclaw_tests.rs | 712 +++++++++++ crates/cli/tests/coverage/setup_tests.rs | 13 +- 16 files changed, 2423 insertions(+), 22 deletions(-) create mode 100644 crates/cli/src/openclaw.rs create mode 100644 crates/cli/tests/coverage/openclaw_tests.rs diff --git a/Cargo.lock b/Cargo.lock index a3d44f449..a78b61dd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1271,6 +1271,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "jsonschema" version = "0.46.8" @@ -1541,7 +1552,9 @@ dependencies = [ "futures-util", "http", "http-body-util", + "json5", "jsonschema", + "libc", "nemo-relay", "nemo-relay-adaptive", "nemo-relay-pii-redaction", @@ -1955,6 +1968,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -3376,6 +3431,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" diff --git a/README.md b/README.md index e6d466fa5..3e4aaf6ed 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ shared runtime for scopes, policy, plugins, and lifecycle events. | Goal | Start With... | |---|---| -| Observe Codex, Claude Code, or Hermes locally via CLI | [Quick Start CLI](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/about) | +| Observe Codex, Claude Code, Hermes, or OpenClaw locally via CLI | [Quick Start CLI](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/about) | | Instrument app-owned LLM or tool calls | [Quick Start Application](https://docs.nvidia.com/nemo/relay/getting-started/quick-start) | | Use LangChain, LangGraph, Deep Agents, or OpenClaw | [Supported Integrations](https://docs.nvidia.com/nemo/relay/supported-integrations/about) | | Build a framework or provider integration | [Integrate into Frameworks](https://docs.nvidia.com/nemo/relay/integrate-into-frameworks/about) | @@ -43,9 +43,8 @@ build from. ### Local Agent Trajectory This walkthrough shows an end-to-end quick success setup. Install the -`nemo-relay-cli`, turn on local exporters, run either Codex or Claude Code -through Relay, and check that Relay wrote both raw events and normalized -trajectories. +`nemo-relay-cli`, turn on local exporters, run a supported coding agent through +Relay, and check that Relay wrote both raw events and normalized trajectories. #### 1. Install the CLI @@ -100,7 +99,7 @@ then configure these sections: > Use `nemo-relay plugins edit` _without_ `--project` only if needing to use these > exporter settings in a user-level Relay config instead of a specific project. -#### 3. Run Codex or Claude Code Through Relay +#### 3. Run a Supported Coding Agent Through Relay Use either host CLI that is installed on a machine. For example: @@ -112,6 +111,17 @@ nemo-relay codex -- exec "Summarize this repository." nemo-relay claude -- "Summarize this repository." ``` +OpenClaw can be launched through the same ephemeral gateway when its effective +provider is deterministically API-key-backed: + +```bash +nemo-relay openclaw -- agent --message "Summarize this repository." +``` + +Relay uses a temporary JSON5 include overlay and removes it when the child or +launcher exits. Remote OpenClaw gateways, custom providers, and ambiguous +OAuth/token routes are left unchanged rather than being claimed as intercepted. + Refer to the full [Quick Start CLI](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/about) docs for more options. The transparent wrapper starts a local Relay gateway, injects host-specific hook @@ -288,6 +298,7 @@ coverage. | Claude Code | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed LLM observability are supported. | | Codex | Yes | Yes | Partial | Hook activation is required; missing session-end behavior limits trajectory finalization and full optimization coverage. | | Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed or hook-backed LLM observability are supported. | +| OpenClaw | Yes | Partial | Partial | Local or foreground API-key-backed Anthropic/OpenAI requests can be gateway-routed. Embedded hook/tool telemetry requires the separately installed OpenClaw plugin. | ### Public API Integrations diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index b38d57413..32817d648 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -40,6 +40,8 @@ http = "1" http-body-util = "0.1" dialoguer = { version = "0.11", default-features = false, features = ["password"] } jsonschema = { version = "0.46.6", default-features = false } +json5 = "0.4" +libc = "0.2" percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "json", "rustls-tls-native-roots", "stream"] } regex = "1" diff --git a/crates/cli/README.md b/crates/cli/README.md index b5436a5d6..612ea1819 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -26,7 +26,7 @@ with the installed `nemo-relay` command rather than link against the crate. ## Why Use It? -- **Observe existing coding agents**: Run Claude Code, Codex, or Hermes +- **Observe existing coding agents**: Run Claude Code, Codex, Hermes, or OpenClaw Agent through a local NeMo Relay gateway without changing the agent itself. - **Configure hooks interactively**: Use the setup wizard to write project or @@ -43,8 +43,8 @@ with the installed `nemo-relay` command rather than link against the crate. Cargo package. - **First-run setup**: Bare `nemo-relay` launches setup when no config exists, then runs doctor once config is present. -- **Agent shortcuts**: `nemo-relay claude`, `nemo-relay codex`, and - `nemo-relay hermes` start observed agent runs. +- **Agent shortcuts**: `nemo-relay claude`, `nemo-relay codex`, + `nemo-relay hermes`, and `nemo-relay openclaw` start observed agent runs. - **Config-driven launch**: `nemo-relay run` resolves config, environment, and CLI overrides for deterministic non-interactive use. - **Hook forwarding server**: A local gateway accepts agent hook events and @@ -99,8 +99,16 @@ Run a supported agent through the gateway: ```bash nemo-relay codex nemo-relay claude -- "summarize this repository" +nemo-relay openclaw -- agent --message "summarize this repository" ``` +OpenClaw launches in an embedded local or foreground-gateway mode so the +temporary provider overlay reaches the model-serving process. Relay routes +deterministically API-key-backed Anthropic Messages, OpenAI Chat Completions, +and OpenAI Responses providers while leaving custom, remote, or ambiguous +provider paths unchanged. Relay detects the optional `nemo-relay-openclaw` +plugin but never installs or enables it automatically. + Use `run --dry-run` to inspect resolved config without spawning the agent: ```bash diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index d4491d6f6..7aca0f23f 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -69,6 +69,18 @@ pub(crate) enum Command { nemo-relay hermes -- chat --provider custom" )] Hermes(EasyPathCommand), + /// Run OpenClaw with observability (setup on first use) + #[command( + long_about = "Run OpenClaw under an ephemeral NeMo Relay gateway. Relay creates a \ + temporary JSON5 overlay and routes API-key-backed canonical Anthropic and \ + OpenAI providers through the gateway without modifying OpenClaw's source \ + configuration, state, credentials, or plugin installation.", + after_help = "Examples:\n \ + nemo-relay openclaw\n \ + nemo-relay openclaw -- agent --agent main --message \"summarize this project\"\n \ + nemo-relay openclaw -- gateway run" + )] + Openclaw(EasyPathCommand), /// Run the interactive setup (writes `.nemo-relay/config.toml`) Config(ConfigCommand), /// Create or edit plugin configuration (writes `plugins.toml`) @@ -526,6 +538,7 @@ pub(crate) enum CodingAgent { ClaudeCode, Codex, Hermes, + Openclaw, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] @@ -613,6 +626,7 @@ pub(crate) struct AgentConfigs { pub(crate) claude: AgentCommandConfig, pub(crate) codex: AgentCommandConfig, pub(crate) hermes: AgentCommandConfig, + pub(crate) openclaw: AgentCommandConfig, } #[derive(Debug, Clone, Default)] @@ -646,12 +660,13 @@ struct FileUpstreamConfig { #[derive(Debug, Clone, Default, Deserialize)] struct FileAgentsConfig { - // Keys match the agent's CLI invocation name (`claude`, `codex`, `hermes`) — the + // Keys match the agent's CLI invocation name (`claude`, `codex`, `hermes`, `openclaw`) — the // word the user types at the shell — not the product name ("Claude Code") or the internal // `CodingAgent` enum kebab spelling. Same convention as the bare-agent shortcut in Phase 2. claude: Option, codex: Option, hermes: Option, + openclaw: Option, } #[derive(Debug, Clone, Default, Deserialize)] @@ -1243,6 +1258,9 @@ fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option "/hooks/claude-code", Self::Codex => "/hooks/codex", Self::Hermes => "/hooks/hermes", + // OpenClaw hook/tool telemetry stays in the separately installed npm plugin. The + // hidden hook-forward command rejects this path before issuing an HTTP request. + Self::Openclaw => "/hooks/openclaw", } } @@ -1362,6 +1383,7 @@ impl CodingAgent { Self::ClaudeCode => "claude", Self::Codex => "codex", Self::Hermes => "hermes", + Self::Openclaw => "openclaw", } } @@ -1376,6 +1398,7 @@ impl CodingAgent { "claude" | "claude-code" => Some(Self::ClaudeCode), "codex" => Some(Self::Codex), "hermes" | "hermes-agent" => Some(Self::Hermes), + "openclaw" | "openclaw.exe" => Some(Self::Openclaw), _ => None, } } diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index b272d5388..897333d73 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -397,6 +397,7 @@ async fn collect_agents( (CodingAgent::ClaudeCode, "claude", "claude"), (CodingAgent::Codex, "codex", "codex"), (CodingAgent::Hermes, "hermes", "hermes"), + (CodingAgent::Openclaw, "openclaw", "openclaw"), ]; let mut out = Vec::with_capacity(supported.len()); for (agent, display_name, default_exec) in supported { @@ -473,6 +474,7 @@ fn configured_agent_command(agent: CodingAgent, agents: &AgentConfigs) -> Option CodingAgent::ClaudeCode => agents.claude.command.as_ref(), CodingAgent::Codex => agents.codex.command.as_ref(), CodingAgent::Hermes => agents.hermes.command.as_ref(), + CodingAgent::Openclaw => agents.openclaw.command.as_ref(), } } @@ -486,6 +488,7 @@ fn configured_agent_names(agents: &AgentConfigs) -> Vec { (CodingAgent::ClaudeCode, "claude"), (CodingAgent::Codex, "codex"), (CodingAgent::Hermes, "hermes"), + (CodingAgent::Openclaw, "openclaw"), ] .into_iter() .filter_map(|(agent, name)| agent_configured(agent, agents).then_some(name.to_string())) @@ -533,6 +536,34 @@ fn hook_status( ), None => (Status::Info, "hooks: not configured".into()), }, + CodingAgent::Openclaw => match crate::openclaw::detect_relay_plugin() { + crate::openclaw::RelayPluginDetection::DetectedEnabled => ( + Status::Pass, + "hook/tool telemetry: nemo-relay-openclaw detected".into(), + ), + crate::openclaw::RelayPluginDetection::DetectedDisabled => ( + Status::Info, + "hook/tool telemetry: nemo-relay-openclaw is installed but disabled".into(), + ), + crate::openclaw::RelayPluginDetection::DetectedError => ( + Status::Warn, + "hook/tool telemetry: nemo-relay-openclaw is installed but failed to load".into(), + ), + crate::openclaw::RelayPluginDetection::Detected => ( + Status::Pass, + "hook/tool telemetry: nemo-relay-openclaw is installed".into(), + ), + crate::openclaw::RelayPluginDetection::NotDetected => ( + Status::Info, + "hook/tool telemetry: optional nemo-relay-openclaw plugin not detected; Relay does not install it automatically" + .into(), + ), + crate::openclaw::RelayPluginDetection::Unknown => ( + Status::Info, + "hook/tool telemetry: could not inspect optional nemo-relay-openclaw plugin state" + .into(), + ), + }, } } diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index 364f229e9..cfe4ded04 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -82,6 +82,13 @@ fn read_hook_payload() -> Result { // Builds the target gateway hook URL and applies fail-open/fail-closed behavior for missing // gateway discovery. Returning `Ok(None)` is the fail-open path used by default hook commands. fn hook_forward_url(command: &HookForwardCommand) -> Result, CliError> { + if command.agent == CodingAgent::Openclaw { + return Err(CliError::Install( + "OpenClaw hook and tool telemetry is provided by the optional \ + nemo-relay-openclaw npm plugin, not the Relay hook-forward endpoint" + .into(), + )); + } let Some(gateway_url) = resolve_hook_gateway_url( command.agent, command.gateway_url.clone(), @@ -201,6 +208,7 @@ pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { CodingAgent::ClaudeCode => claude_hooks(command), CodingAgent::Codex => codex_hooks(command), CodingAgent::Hermes => hermes_hooks(command), + CodingAgent::Openclaw => json!({"hooks": {}}), } } diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 0147c83a0..09d2e6f18 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::future::Future; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -101,6 +102,12 @@ impl TransparentRun { async fn new(command: RunCommand, inherited: Option<&ServerArgs>) -> Result { let dry_run = command.dry_run; let print = command.print; + let explicit_openclaw_upstreams = crate::openclaw::ExplicitUpstreams { + openai: command.openai_base_url.is_some() + || inherited.is_some_and(|args| args.openai_base_url.is_some()), + anthropic: command.anthropic_base_url.is_some() + || inherited.is_some_and(|args| args.anthropic_base_url.is_some()), + }; let explicit_config = command .config .as_ref() @@ -111,13 +118,30 @@ impl TransparentRun { } else { crate::plugins::lifecycle::active_dynamic_plugin_components(explicit_config, &resolved)? }; - let (agent, argv) = resolve_agent_and_argv(&command, &resolved.agents)?; + let (agent, mut argv) = resolve_agent_and_argv(&command, &resolved.agents)?; let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; let gateway_url = format!("http://{address}"); resolved.gateway.bind = address; - let prepared = PreparedRun::new(agent, argv, &gateway_url, &resolved, dry_run)?; + let openclaw = if agent == CodingAgent::Openclaw { + Some(crate::openclaw::prepare( + &mut argv, + &gateway_url, + &mut resolved.gateway, + explicit_openclaw_upstreams, + dry_run, + )?) + } else { + None + }; + let mut prepared = PreparedRun::new(agent, argv, &gateway_url, &resolved, dry_run)?; + if let Some(openclaw) = openclaw { + prepared.env.extend(openclaw.env); + prepared.temp_dirs.extend(openclaw.temp_dirs); + prepared.temp_files.extend(openclaw.temp_files); + prepared.notes.extend(openclaw.notes); + } Ok(Self { agent, prepared, @@ -176,15 +200,49 @@ async fn execute_live_run_with_dynamic( gateway_url: &str, prepared: PreparedRun, ) -> Result { + execute_live_run_with_dynamic_and_shutdown( + listener, + gateway_config, + dynamic_plugins, + gateway_url, + prepared, + launcher_shutdown_signal(), + ) + .await +} + +async fn execute_live_run_with_dynamic_and_shutdown( + listener: TcpListener, + gateway_config: GatewayConfig, + dynamic_plugins: Vec, + gateway_url: &str, + prepared: PreparedRun, + shutdown: F, +) -> Result +where + F: Future>, +{ let running_server = RunningGateway::start(listener, gateway_config, dynamic_plugins); - if let Err(error) = wait_for_health(gateway_url).await { + tokio::pin!(shutdown); + let health = tokio::select! { + result = wait_for_health(gateway_url) => result, + signal = &mut shutdown => { + let restore = prepared.restore(); + let server_result = running_server.stop().await; + signal?; + restore?; + server_result?; + return Ok(ExitCode::FAILURE); + } + }; + if let Err(error) = health { let restore = prepared.restore(); let server_result = running_server.stop().await; restore?; server_result?; return Err(error); } - let status = prepared.spawn_and_wait().await; + let status = prepared.spawn_and_wait_with_shutdown(&mut shutdown).await; let restore = prepared.restore(); let server_result = running_server.stop().await; restore?; @@ -193,6 +251,28 @@ async fn execute_live_run_with_dynamic( Ok(exit_code(status?)) } +#[cfg(test)] +async fn execute_live_run_with_shutdown( + listener: TcpListener, + gateway_config: GatewayConfig, + gateway_url: &str, + prepared: PreparedRun, + shutdown: F, +) -> Result +where + F: Future>, +{ + execute_live_run_with_dynamic_and_shutdown( + listener, + gateway_config, + Vec::new(), + gateway_url, + prepared, + shutdown, + ) + .await +} + // Resolves the launched agent and argv from either an explicit command or a configured per-agent // command. Agent inference only happens from argv[0] when `--agent` was omitted, so explicit agent // selection can wrap commands whose executable name is not recognizable. @@ -232,6 +312,7 @@ const fn default_command_for(agent: CodingAgent) -> &'static str { CodingAgent::ClaudeCode => "claude", CodingAgent::Codex => "codex", CodingAgent::Hermes => "hermes", + CodingAgent::Openclaw => "openclaw", } } @@ -243,7 +324,7 @@ fn resolved_agent(command: &RunCommand, argv: &[String]) -> Result Option agents.claude.command.as_ref(), CodingAgent::Codex => agents.codex.command.as_ref(), CodingAgent::Hermes => agents.hermes.command.as_ref(), + CodingAgent::Openclaw => agents.openclaw.command.as_ref(), }?; let argv: Vec<_> = command.split_whitespace().map(ToOwned::to_owned).collect(); (!argv.is_empty()).then_some(argv) @@ -266,6 +348,7 @@ struct PreparedRun { argv: Vec, env: Vec<(String, String)>, temp_dirs: Vec, + temp_files: Vec, hermes_restore: Option, notes: Vec, } @@ -281,6 +364,17 @@ struct RunningGateway { task: JoinHandle>, } +impl Drop for PreparedRun { + fn drop(&mut self) { + for file in &self.temp_files { + let _ = std::fs::remove_file(file); + } + for dir in &self.temp_dirs { + let _ = std::fs::remove_dir_all(dir); + } + } +} + impl RunningGateway { // Starts the gateway listener on a background task and keeps the shutdown sender paired with the // task handle so health failures and normal exits use identical cleanup semantics. @@ -312,6 +406,37 @@ impl RunningGateway { } } +async fn launcher_shutdown_signal() -> Result<(), CliError> { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .map_err(CliError::from)?; + tokio::select! { + result = tokio::signal::ctrl_c() => result.map_err(CliError::from), + _ = terminate.recv() => Ok(()), + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await.map_err(CliError::from) + } +} + +fn request_child_shutdown(child: &mut tokio::process::Child) { + #[cfg(unix)] + if let Some(pid) = child.id() { + // The child may already have received the terminal's SIGINT/SIGTERM through the + // foreground process group. A second graceful SIGTERM is harmless and also covers + // service managers that signal only the Relay parent. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGTERM); + } + } + #[cfg(not(unix))] + let _ = child; +} + impl PreparedRun { // Builds the launch plan and applies only the preparation needed by the selected agent. // Dry-run preparation records equivalent notes and argv/env changes without writing temporary @@ -327,6 +452,7 @@ impl PreparedRun { argv, env: vec![("NEMO_RELAY_GATEWAY_URL".into(), gateway_url.into())], temp_dirs: Vec::new(), + temp_files: Vec::new(), hermes_restore: None, notes: Vec::new(), }; @@ -349,6 +475,9 @@ impl PreparedRun { run.prepare_hermes(resolved.agents.hermes.hooks_path.as_deref())?; } } + // OpenClaw preparation happens before this generic constructor because it also + // selects provider-specific upstreams on the gateway configuration. + CodingAgent::Openclaw => {} } Ok(run) } @@ -489,14 +618,34 @@ impl PreparedRun { // Spawns the prepared child process with injected environment and waits for its exit status. // Stdio is inherited by default so agent interaction remains unchanged in transparent mode. - async fn spawn_and_wait(&self) -> Result { + async fn spawn_and_wait_with_shutdown( + &self, + shutdown: F, + ) -> Result + where + F: Future>, + { let mut command = Command::new(&self.argv[0]); command.args(&self.argv[1..]); for (name, value) in &self.env { command.env(name, value); } let mut child = command.spawn()?; - child.wait().await.map_err(CliError::from) + tokio::pin!(shutdown); + tokio::select! { + status = child.wait() => status.map_err(CliError::from), + signal = &mut shutdown => { + signal?; + request_child_shutdown(&mut child); + match tokio::time::timeout(Duration::from_secs(5), child.wait()).await { + Ok(status) => status.map_err(CliError::from), + Err(_) => { + let _ = child.start_kill(); + child.wait().await.map_err(CliError::from) + } + } + } + } } // Removes temporary directories and restores patched hook files after the child exits. Restore @@ -505,6 +654,16 @@ impl PreparedRun { for dir in &self.temp_dirs { let _ = std::fs::remove_dir_all(dir); } + for file in &self.temp_files { + if let Err(error) = std::fs::remove_file(file) + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(CliError::Launch(format!( + "failed to remove temporary launcher file {}: {error}", + file.display() + ))); + } + } if let Some(hermes) = &self.hermes_restore { restore_hook_file( @@ -520,14 +679,21 @@ impl PreparedRun { // Prints a compact pre-launch status banner so users see at a glance which plugin // configuration is active, including plugin names and enabled/disabled state, before the // agent's own UI takes over the terminal. Always emitted on stderr so it never contaminates - // piped/redirected agent output, and suppressed entirely when stdout is not a TTY — scripts - // capturing the agent stream get a clean pipe, interactive users still get the bordered frame. + // piped/redirected agent output. The decorative frame is suppressed when stdout is not a TTY, + // but OpenClaw routing decisions still go to stderr so passthrough is never silent. // Distinct from `print()`, which is the verbose `--print` / `--dry-run` dump intended for // inspection. fn print_live_status(&self, agent: CodingAgent, gateway_url: &str, resolved: &ResolvedConfig) { // Suppress entirely on non-TTY stdout: when the user redirects the agent's stream to a - // file or pipes it into another tool, no banner should appear ahead of that output. + // file or pipes it into another tool, no banner should appear ahead of that output. Keep + // OpenClaw routing notes visible on stderr because they are part of the interception + // contract rather than decoration. if !std::io::IsTerminal::is_terminal(&std::io::stdout()) { + if agent == CodingAgent::Openclaw { + for note in &self.notes { + eprintln!("nemo-relay openclaw: {note}"); + } + } return; } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d5947b0b0..a7d6ad838 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -16,6 +16,7 @@ mod json_path; mod launcher; mod model; mod model_pricing; +mod openclaw; mod plugin_install; mod plugin_shim; mod plugins; @@ -80,6 +81,9 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result { launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await } + Command::Openclaw(command) => { + launcher::easy_path(CodingAgent::Openclaw, command, Some(server)).await + } Command::Config(command) => run_config(command).await, Command::Plugins(command) => run_plugins(command, server), Command::ModelPricing(command) => run_pricing(command), diff --git a/crates/cli/src/openclaw.rs b/crates/cli/src/openclaw.rs new file mode 100644 index 000000000..9f1309e6a --- /dev/null +++ b/crates/cli/src/openclaw.rs @@ -0,0 +1,1173 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OpenClaw transparent-launch preparation. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Map, Value, json}; + +use crate::config::GatewayConfig; +use crate::error::CliError; + +const OPENCLAW_CONFIG_PATH: &str = "OPENCLAW_CONFIG_PATH"; +const RELAY_OPENCLAW_PLUGIN_ID: &str = "nemo-relay"; +const RELAY_OPENCLAW_PACKAGE: &str = "nemo-relay-openclaw"; +const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; +const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com"; + +/// Records which Relay upstream flags were explicitly supplied by the caller. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct ExplicitUpstreams { + pub(crate) openai: bool, + pub(crate) anthropic: bool, +} + +/// Files, environment, and status notes added to an OpenClaw child process. +#[derive(Debug, Default)] +pub(crate) struct Preparation { + pub(crate) env: Vec<(String, String)>, + pub(crate) temp_dirs: Vec, + pub(crate) temp_files: Vec, + pub(crate) notes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RelayPluginDetection { + DetectedEnabled, + DetectedDisabled, + DetectedError, + Detected, + NotDetected, + Unknown, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum AuthProfileEvidence { + /// The read-only OpenClaw probe found no saved profiles for this provider. + #[default] + None, + /// Every profile OpenClaw may select is an API key. + ApiKeyOnly, + /// Some agents have API-key profiles and the others have no saved profile. + ApiKeyOrNone, + /// At least one profile is OAuth/token-based, so selection or rotation is not deterministic. + NonApiKeyOrMixed, + /// The profile inventory could not be read safely. + Unknown, +} + +#[derive(Debug, Clone, Copy, Default)] +struct AuthProfileEvidenceSet { + openai: AuthProfileEvidence, + anthropic: AuthProfileEvidence, +} + +#[derive(Debug, Clone, Copy, Default)] +struct TrustedApiKeySet { + openai: bool, + anthropic: bool, +} + +impl TrustedApiKeySet { + const fn get(self, family: ProviderFamily) -> bool { + match family { + ProviderFamily::OpenAi => self.openai, + ProviderFamily::Anthropic => self.anthropic, + } + } +} + +impl AuthProfileEvidenceSet { + const fn get(self, family: ProviderFamily) -> AuthProfileEvidence { + match family { + ProviderFamily::OpenAi => self.openai, + ProviderFamily::Anthropic => self.anthropic, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProviderFamily { + OpenAi, + Anthropic, +} + +impl ProviderFamily { + const fn id(self) -> &'static str { + match self { + Self::OpenAi => "openai", + Self::Anthropic => "anthropic", + } + } + + const fn default_api(self) -> &'static str { + match self { + Self::OpenAi => "openai-responses", + Self::Anthropic => "anthropic-messages", + } + } + + const fn default_base_url(self) -> &'static str { + match self { + Self::OpenAi => DEFAULT_OPENAI_BASE_URL, + Self::Anthropic => DEFAULT_ANTHROPIC_BASE_URL, + } + } + + fn is_api_key_name(self, name: &str) -> bool { + let exact = match self { + Self::OpenAi => [ + "OPENAI_API_KEY", + "OPENAI_API_KEYS", + "OPENCLAW_LIVE_OPENAI_KEY", + ], + Self::Anthropic => [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_API_KEYS", + "OPENCLAW_LIVE_ANTHROPIC_KEY", + ], + }; + exact.contains(&name) + || match self { + Self::OpenAi => name.starts_with("OPENAI_API_KEY_"), + Self::Anthropic => name.starts_with("ANTHROPIC_API_KEY_"), + } + } + + fn has_api_key_environment(self) -> bool { + std::env::vars_os().any(|(name, value)| { + self.is_api_key_name(&name.to_string_lossy()) + && !value.to_string_lossy().trim().is_empty() + }) + } +} + +#[derive(Debug)] +struct RoutingPlan { + provider_overrides: Map, + openai_upstream: Option, + anthropic_upstream: Option, + notes: Vec, +} + +/// Prepare an OpenClaw process that owns the model request path. +/// +/// OpenClaw's `agent` and `tui` commands normally attach to a separately running +/// gateway. Those modes would leave the temporary config in the client process, +/// not the model-serving process, so this function forces their embedded local +/// modes. A foreground `gateway run` is also supported. Remote and detached +/// gateway modes fail before Relay claims interception. +pub(crate) fn prepare( + argv: &mut Vec, + gateway_url: &str, + gateway: &mut GatewayConfig, + explicit_upstreams: ExplicitUpstreams, + dry_run: bool, +) -> Result { + normalize_foreground_argv(argv)?; + add_invocation_metadata(gateway); + + let source_path = resolve_config_path(argv)?; + let (source_config, source_exists) = read_source_config(&source_path)?; + let auth_profiles = AuthProfileEvidenceSet { + openai: probe_auth_profiles( + argv, + &source_path, + source_config.as_ref(), + ProviderFamily::OpenAi, + ), + anthropic: probe_auth_profiles( + argv, + &source_path, + source_config.as_ref(), + ProviderFamily::Anthropic, + ), + }; + let trusted_api_keys = trusted_api_key_sources(argv, source_config.as_ref()); + let mut routing = build_routing_plan_with_evidence( + source_config.as_ref(), + gateway_url, + auth_profiles, + trusted_api_keys, + ); + if let Some(upstream) = routing.openai_upstream.take() { + if explicit_upstreams.openai { + routing.notes.push( + "explicit Relay OpenAI upstream overrides the OpenClaw provider endpoint".into(), + ); + } else { + gateway.openai_base_url = upstream; + } + } + if let Some(upstream) = routing.anthropic_upstream.take() { + if explicit_upstreams.anthropic { + routing.notes.push( + "explicit Relay Anthropic upstream overrides the OpenClaw provider endpoint".into(), + ); + } else { + gateway.anthropic_base_url = upstream; + } + } + + routing.notes.push(plugin_detection_note(probe_relay_plugin( + argv, + &source_path, + ))); + + let overlay = overlay_document( + source_exists.then_some(source_path.as_path()), + routing.provider_overrides, + ); + if dry_run { + let mut notes = routing.notes; + notes.push(format!( + "would generate a temporary OpenClaw JSON5 overlay for {}", + source_path.display() + )); + return Ok(Preparation { + env: vec![( + OPENCLAW_CONFIG_PATH.into(), + "".into(), + )], + temp_dirs: Vec::new(), + temp_files: Vec::new(), + notes, + }); + } + + let overlay_path = temporary_overlay_path(&source_path)?; + let write_result = serde_json::to_vec_pretty(&overlay) + .map_err(|error| CliError::Launch(format!("could not serialize OpenClaw overlay: {error}"))) + .and_then(|contents| std::fs::write(&overlay_path, contents).map_err(CliError::from)); + if let Err(error) = write_result { + let _ = std::fs::remove_file(&overlay_path); + return Err(error); + } + + let env = vec![( + OPENCLAW_CONFIG_PATH.into(), + overlay_path.display().to_string(), + )]; + Ok(Preparation { + env, + temp_dirs: Vec::new(), + temp_files: vec![overlay_path], + notes: routing.notes, + }) +} + +pub(crate) fn detect_relay_plugin() -> RelayPluginDetection { + let argv = vec!["openclaw".to_string()]; + let Ok(source_path) = resolve_config_path(&argv) else { + return RelayPluginDetection::Unknown; + }; + probe_relay_plugin(&argv, &source_path) +} + +fn normalize_foreground_argv(argv: &mut Vec) -> Result<(), CliError> { + let executable = openclaw_executable_index(argv).ok_or_else(|| { + CliError::Launch( + "could not locate the OpenClaw executable in the configured launch prefix; use `openclaw`, `npx openclaw`, or a supported package-manager exec form" + .into(), + ) + })?; + if has_container_option(&argv[executable + 1..]) { + return Err(CliError::Launch( + "OpenClaw --container execution cannot use Relay's host-side temporary provider overlay; run OpenClaw directly or launch a foreground host process" + .into(), + )); + } + let command = first_openclaw_positional(argv, executable) + .map(|(index, value)| (index, value.to_string())); + + let Some((command_index, command)) = command else { + if argv.len() == executable + 1 || contains_only_root_options(&argv[executable + 1..]) { + argv.extend(["tui".into(), "--local".into()]); + } + return Ok(()); + }; + + match command.as_str() { + "agent" => insert_local_flag(argv, command_index), + "tui" | "chat" | "terminal" => { + if has_remote_tui_options(&argv[command_index + 1..]) { + return Err(CliError::Launch( + "OpenClaw remote TUI options (--url, --token, or --password) bypass the \ + process carrying Relay's temporary provider overlay; use local mode or run \ + the target OpenClaw gateway in the foreground under Relay" + .into(), + )); + } + insert_local_flag(argv, command_index); + } + "gateway" => { + let action = argv[command_index + 1..] + .iter() + .find(|value| !value.starts_with('-')) + .map(String::as_str); + if action != Some("run") { + return Err(CliError::Launch( + "OpenClaw gateway interception requires the foreground `gateway run` \ + command; detached, service-managed, or remote gateways cannot retain \ + Relay's temporary configuration overlay" + .into(), + )); + } + } + other => { + return Err(CliError::Launch(format!( + "OpenClaw command `{other}` is not a supported Relay launch target; run configuration, plugin, model-management, and onboarding commands directly so their writes are not redirected into Relay's temporary overlay" + ))); + } + } + Ok(()) +} + +fn contains_only_root_options(args: &[String]) -> bool { + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--dev" => index += 1, + "--profile" | "--log-level" if args.get(index + 1).is_some() => index += 2, + value if value.starts_with("--profile=") || value.starts_with("--log-level=") => { + index += 1; + } + _ => return false, + } + } + true +} + +fn has_container_option(args: &[String]) -> bool { + args.iter() + .any(|value| value == "--container" || value.starts_with("--container=")) +} + +fn insert_local_flag(argv: &mut Vec, command_index: usize) { + if !argv[command_index + 1..] + .iter() + .any(|value| value == "--local") + { + argv.insert(command_index + 1, "--local".into()); + } +} + +fn has_remote_tui_options(args: &[String]) -> bool { + args.iter().any(|value| { + matches!(value.as_str(), "--url" | "--token" | "--password") + || value.starts_with("--url=") + || value.starts_with("--token=") + || value.starts_with("--password=") + }) +} + +fn is_openclaw_executable(value: &str) -> bool { + let name = Path::new(value) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(value); + matches!(name, "openclaw" | "openclaw.exe") +} + +#[cfg(test)] +fn build_routing_plan(config: Option<&Value>, gateway_url: &str) -> RoutingPlan { + build_routing_plan_with_auth(config, gateway_url, AuthProfileEvidenceSet::default()) +} + +#[cfg(test)] +fn build_routing_plan_with_auth( + config: Option<&Value>, + gateway_url: &str, + auth_profiles: AuthProfileEvidenceSet, +) -> RoutingPlan { + build_routing_plan_with_evidence( + config, + gateway_url, + auth_profiles, + TrustedApiKeySet { + openai: ProviderFamily::OpenAi.has_api_key_environment() + || config + .is_some_and(|config| config_env_has_api_key(config, ProviderFamily::OpenAi)), + anthropic: ProviderFamily::Anthropic.has_api_key_environment() + || config.is_some_and(|config| { + config_env_has_api_key(config, ProviderFamily::Anthropic) + }), + }, + ) +} + +fn build_routing_plan_with_evidence( + config: Option<&Value>, + gateway_url: &str, + auth_profiles: AuthProfileEvidenceSet, + trusted_api_keys: TrustedApiKeySet, +) -> RoutingPlan { + let mut plan = RoutingPlan { + provider_overrides: Map::new(), + openai_upstream: None, + anthropic_upstream: None, + notes: Vec::new(), + }; + if config.is_some_and(contains_provider_include) { + plan.notes.push( + "OpenClaw provider routing left unchanged because its provider configuration uses \ + `$include`; Relay will not guess at an unresolved upstream" + .into(), + ); + return plan; + } + + let providers = config.and_then(provider_map); + if let Some(providers) = providers { + let custom = providers + .keys() + .filter(|id| id.as_str() != "openai" && id.as_str() != "anthropic") + .cloned() + .collect::>(); + if !custom.is_empty() { + plan.notes.push(format!( + "custom OpenClaw providers left unchanged and not intercepted: {}", + custom.join(", ") + )); + } + } + + for family in [ProviderFamily::OpenAi, ProviderFamily::Anthropic] { + if config.is_some_and(|config| has_non_http_agent_model_runtime(config, family)) { + plan.notes.push(format!( + "OpenClaw provider `{}` left unchanged and not intercepted: agent model policy selects a non-HTTP runtime", + family.id() + )); + continue; + } + let provider = providers.and_then(|providers| providers.get(family.id())); + if let Some(reason) = unsupported_provider_reason(family, provider) { + plan.notes.push(format!( + "OpenClaw provider `{}` left unchanged and not intercepted: {reason}", + family.id() + )); + continue; + } + if let Err(reason) = provider_uses_api_key( + provider, + auth_profiles.get(family), + trusted_api_keys.get(family), + ) { + plan.notes.push(format!( + "OpenClaw provider `{}` left unchanged: {reason}", + family.id(), + )); + continue; + } + + let api = provider + .and_then(Value::as_object) + .and_then(|provider| provider.get("api")) + .and_then(Value::as_str) + .unwrap_or_else(|| family.default_api()); + let upstream = provider + .and_then(Value::as_object) + .and_then(|provider| provider.get("baseUrl")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| family.default_base_url()) + .to_string(); + let relay_base_url = match family { + ProviderFamily::OpenAi => format!("{}/v1", gateway_url.trim_end_matches('/')), + ProviderFamily::Anthropic => gateway_url.trim_end_matches('/').to_string(), + }; + plan.provider_overrides.insert( + family.id().into(), + json!({ + "baseUrl": relay_base_url, + "agentRuntime": {"id": "openclaw"} + }), + ); + match family { + ProviderFamily::OpenAi => plan.openai_upstream = Some(upstream.clone()), + ProviderFamily::Anthropic => plan.anthropic_upstream = Some(upstream.clone()), + } + plan.notes.push(format!( + "OpenClaw provider `{}` routed through Relay using {api}; original upstream preserved", + family.id() + )); + } + plan +} + +fn has_non_http_agent_model_runtime(config: &Value, family: ProviderFamily) -> bool { + let provider_prefix = format!("{}/", family.id()); + let has_override = |models: &Map| { + models.iter().any(|(model_ref, model)| { + model_ref.starts_with(&provider_prefix) + && model.get("agentRuntime").is_some() + && model.pointer("/agentRuntime/id").and_then(Value::as_str) != Some("openclaw") + }) + }; + + config + .pointer("/agents/defaults/models") + .and_then(Value::as_object) + .is_some_and(&has_override) + || config + .pointer("/agents/list") + .and_then(Value::as_array) + .is_some_and(|agents| { + agents.iter().any(|agent| { + agent + .get("models") + .and_then(Value::as_object) + .is_some_and(&has_override) + }) + }) +} + +fn unsupported_provider_reason(family: ProviderFamily, provider: Option<&Value>) -> Option { + let provider_value = provider?; + let Some(provider) = provider_value.as_object() else { + return Some("provider configuration is not an object".into()); + }; + if provider.contains_key("baseUrl") + && provider + .get("baseUrl") + .and_then(Value::as_str) + .is_none_or(|value| value.trim().is_empty()) + { + return Some("baseUrl is not a non-empty string".into()); + } + if provider + .get("baseUrl") + .and_then(Value::as_str) + .is_some_and(|value| value.contains("${")) + { + return Some( + "environment-substituted baseUrl cannot be preserved safely by the launcher".into(), + ); + } + if provider.contains_key("agentRuntime") + && provider + .get("agentRuntime") + .and_then(|runtime| runtime.get("id")) + .and_then(Value::as_str) + != Some("openclaw") + { + return Some("its explicit agent runtime is not the direct OpenClaw HTTP runtime".into()); + } + if provider + .get("models") + .and_then(Value::as_array) + .is_some_and(|models| { + models.iter().any(|model| { + model.as_object().is_some_and(|model| { + model.contains_key("api") + || model.contains_key("baseUrl") + || (model.contains_key("agentRuntime") + && model + .get("agentRuntime") + .and_then(|runtime| runtime.get("id")) + .and_then(Value::as_str) + != Some("openclaw")) + }) + }) + }) + { + return Some( + "model-level API, base URL, or agent-runtime overrides cannot be routed safely".into(), + ); + } + let api = provider.get("api").and_then(Value::as_str)?; + let supported = match family { + ProviderFamily::OpenAi => matches!(api, "openai-completions" | "openai-responses"), + ProviderFamily::Anthropic => api == "anthropic-messages", + }; + (!supported).then(|| format!("unsupported API adapter `{api}`")) +} + +fn provider_uses_api_key( + provider: Option<&Value>, + profiles: AuthProfileEvidence, + trusted_api_key: bool, +) -> Result<(), &'static str> { + let provider = provider.and_then(Value::as_object); + let explicit_api_key = provider.is_some_and(|provider| { + provider.get("auth").and_then(Value::as_str) == Some("api-key") + || provider.get("apiKey").is_some_and(|value| match value { + Value::String(value) => !value.trim().is_empty(), + Value::Object(_) => true, + _ => false, + }) + }); + if provider + .and_then(|provider| provider.get("auth")) + .and_then(Value::as_str) + .is_some_and(|auth| auth != "api-key") + { + return Err("its explicit authentication mode is not API-key based"); + } + match profiles { + AuthProfileEvidence::ApiKeyOnly => Ok(()), + AuthProfileEvidence::ApiKeyOrNone if explicit_api_key || trusted_api_key => Ok(()), + AuthProfileEvidence::ApiKeyOrNone => Err( + "some OpenClaw agents have no saved API-key profile and no shared API key was detected", + ), + AuthProfileEvidence::NonApiKeyOrMixed => Err( + "OpenClaw auth profiles include non-API-key or mixed credential types, so selection is ambiguous", + ), + AuthProfileEvidence::Unknown => { + Err("OpenClaw auth-profile state could not be verified as API-key-only") + } + AuthProfileEvidence::None if explicit_api_key || trusted_api_key => Ok(()), + AuthProfileEvidence::None => { + Err("no API-key-backed configuration or auth profile was detected") + } + } +} + +fn provider_map(config: &Value) -> Option<&Map> { + config + .get("models") + .and_then(Value::as_object) + .and_then(|models| models.get("providers")) + .and_then(Value::as_object) +} + +fn contains_provider_include(config: &Value) -> bool { + let Some(root) = config.as_object() else { + return false; + }; + root.contains_key("$include") + || ["models", "agents", "auth", "env"] + .into_iter() + .filter_map(|key| root.get(key)) + .any(contains_nested_include) +} + +fn contains_nested_include(config: &Value) -> bool { + match config { + Value::Object(object) => { + object.contains_key("$include") || object.values().any(contains_nested_include) + } + Value::Array(values) => values.iter().any(contains_nested_include), + _ => false, + } +} + +fn trusted_api_key_sources(argv: &[String], config: Option<&Value>) -> TrustedApiKeySet { + let state_dir = probe_state_dir(argv).ok(); + let has_key = |family: ProviderFamily| { + family.has_api_key_environment() + || config.is_some_and(|config| config_env_has_api_key(config, family)) + || state_dir + .as_ref() + .is_some_and(|state| dotenv_has_api_key(&state.join(".env"), family)) + }; + TrustedApiKeySet { + openai: has_key(ProviderFamily::OpenAi), + anthropic: has_key(ProviderFamily::Anthropic), + } +} + +fn config_env_has_api_key(config: &Value, family: ProviderFamily) -> bool { + let Some(env) = config.get("env").and_then(Value::as_object) else { + return false; + }; + env.iter() + .filter(|(name, _)| name.as_str() != "vars") + .any(|(name, value)| family.is_api_key_name(name) && nonempty_string(value)) + || env + .get("vars") + .and_then(Value::as_object) + .is_some_and(|vars| { + vars.iter() + .any(|(name, value)| family.is_api_key_name(name) && nonempty_string(value)) + }) +} + +fn dotenv_has_api_key(path: &Path, family: ProviderFamily) -> bool { + let Ok(contents) = std::fs::read_to_string(path) else { + return false; + }; + contents.lines().any(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return false; + } + let line = line.strip_prefix("export ").unwrap_or(line).trim_start(); + let Some((name, value)) = line.split_once('=') else { + return false; + }; + family.is_api_key_name(name.trim()) && nonempty_dotenv_value(value) + }) +} + +fn nonempty_string(value: &Value) -> bool { + value.as_str().is_some_and(|value| !value.trim().is_empty()) +} + +fn nonempty_dotenv_value(value: &str) -> bool { + let value = value.trim(); + if value.starts_with('#') { + return false; + } + let value = if value.starts_with('\'') || value.starts_with('"') { + value + } else { + value.split(" #").next().unwrap_or(value).trim_end() + }; + !value.is_empty() && value != "\"\"" && value != "''" +} + +fn overlay_document(source: Option<&Path>, providers: Map) -> Value { + let mut overlay = Map::new(); + if let Some(source) = source { + overlay.insert( + "$include".into(), + Value::String(source.display().to_string()), + ); + } + if !providers.is_empty() { + overlay.insert("models".into(), json!({"providers": providers})); + } + Value::Object(overlay) +} + +fn add_invocation_metadata(gateway: &mut GatewayConfig) { + let mut metadata = match gateway.metadata.take() { + Some(Value::Object(metadata)) => metadata, + Some(metadata) => Map::from_iter([("nemo_relay_original_metadata".into(), metadata)]), + None => Map::new(), + }; + metadata.insert( + "nemo_relay_invocation".into(), + json!({"agent": "openclaw", "integration": "cli_launcher"}), + ); + gateway.metadata = Some(Value::Object(metadata)); +} + +fn read_source_config(path: &Path) -> Result<(Option, bool), CliError> { + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((None, false)), + Err(error) => { + return Err(CliError::Launch(format!( + "could not read OpenClaw config {}: {error}", + path.display() + ))); + } + }; + let value: Value = json5::from_str(&raw).map_err(|error| { + CliError::Launch(format!( + "invalid OpenClaw JSON5 config {}: {error}", + path.display() + )) + })?; + if !value.is_object() { + return Err(CliError::Launch(format!( + "OpenClaw config {} must contain a JSON5 object", + path.display() + ))); + } + Ok((Some(value), true)) +} + +fn resolve_config_path(argv: &[String]) -> Result { + if let Some(path) = nonempty_env_os(OPENCLAW_CONFIG_PATH) { + return resolve_openclaw_path(path); + } + let home = effective_home()?; + if let Some(state) = nonempty_env_os("OPENCLAW_STATE_DIR") { + return Ok(resolve_openclaw_path(state)?.join("openclaw.json")); + } + if let Some(profile) = command_profile(argv)? { + return Ok(home + .join(format!(".openclaw-{profile}")) + .join("openclaw.json")); + } + let primary = home.join(".openclaw").join("openclaw.json"); + if primary.exists() { + return Ok(primary); + } + let legacy = home.join(".clawdbot").join("clawdbot.json"); + if legacy.exists() { + return Ok(legacy); + } + Ok(primary) +} + +fn command_profile(argv: &[String]) -> Result, CliError> { + let mut profile = None; + let executable = openclaw_executable_index(argv).ok_or_else(|| { + CliError::Launch("could not locate the OpenClaw executable in the launch prefix".into()) + })?; + let command = first_openclaw_positional(argv, executable) + .map(|(index, _)| index) + .unwrap_or(argv.len()); + let mut index = executable + 1; + while index < command { + match argv[index].as_str() { + "--dev" if profile.is_none() => profile = Some("dev".to_string()), + "--profile" => { + let value = argv.get(index + 1).ok_or_else(|| { + CliError::Launch("OpenClaw --profile requires a profile name".into()) + })?; + profile = Some(value.clone()); + index += 1; + } + value if value.starts_with("--profile=") => { + profile = Some(value.trim_start_matches("--profile=").to_string()); + } + _ => {} + } + index += 1; + } + if let Some(value) = profile.as_deref() + && (value.is_empty() + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + })) + { + return Err(CliError::Launch(format!( + "invalid OpenClaw profile name `{value}`" + ))); + } + Ok(profile) +} + +fn openclaw_executable_index(argv: &[String]) -> Option { + if argv + .first() + .is_some_and(|value| is_openclaw_executable(value)) + { + return Some(0); + } + let launcher = argv + .first() + .and_then(|value| Path::new(value).file_name()) + .and_then(|value| value.to_str())?; + let start = match launcher { + "npx" | "npx.exe" | "bunx" | "bunx.exe" | "pnpx" | "pnpx.exe" => 1, + "npm" | "npm.exe" if matches!(argv.get(1).map(String::as_str), Some("exec" | "x")) => 2, + "pnpm" | "pnpm.exe" | "yarn" | "yarn.exe" + if matches!(argv.get(1).map(String::as_str), Some("exec" | "dlx")) => + { + 2 + } + _ => return None, + }; + for (index, value) in argv.iter().enumerate().skip(start) { + if value == "--" || matches!(value.as_str(), "--yes" | "-y" | "--quiet") { + continue; + } + // The first package/command token in a supported wrapper must be OpenClaw; never scan + // later user arguments for a matching basename. + return is_openclaw_executable(value).then_some(index); + } + None +} + +fn first_openclaw_positional(argv: &[String], executable: usize) -> Option<(usize, &str)> { + let mut index = executable + 1; + while index < argv.len() { + match argv[index].as_str() { + "--profile" | "--log-level" | "--container" if argv.get(index + 1).is_some() => { + index += 2; + } + value + if value.starts_with("--profile=") + || value.starts_with("--log-level=") + || value.starts_with("--container=") => + { + index += 1; + } + value if value.starts_with('-') => index += 1, + value => return Some((index, value)), + } + } + None +} + +fn effective_home() -> Result { + let value = nonempty_env_os("OPENCLAW_HOME") + .or_else(|| nonempty_env_os("HOME")) + .or_else(|| nonempty_env_os("USERPROFILE")); + match value { + Some(value) => resolve_openclaw_path(value), + None => std::env::current_dir().map_err(CliError::from), + } +} + +fn resolve_openclaw_path(value: OsString) -> Result { + let text = value.to_string_lossy(); + let path = if text == "~" || text.starts_with("~/") || text.starts_with("~\\") { + let home = nonempty_env_os("HOME") + .or_else(|| nonempty_env_os("USERPROFILE")) + .ok_or_else(|| { + CliError::Launch("cannot expand OpenClaw path without a home directory".into()) + })?; + PathBuf::from(home).join(text.trim_start_matches('~').trim_start_matches(['/', '\\'])) + } else { + PathBuf::from(value) + }; + if path.is_absolute() { + Ok(path) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +fn probe_relay_plugin(argv: &[String], source_path: &Path) -> RelayPluginDetection { + let args = ["plugins", "inspect", RELAY_OPENCLAW_PLUGIN_ID, "--json"].map(str::to_string); + let Some(output) = run_read_only_probe(argv, source_path, &args) else { + return RelayPluginDetection::Unknown; + }; + if output.status.success() { + let Ok(value) = serde_json::from_slice::(&output.stdout) else { + return RelayPluginDetection::Unknown; + }; + match plugin_status(&value) { + Some("error") => return RelayPluginDetection::DetectedError, + Some("disabled") => return RelayPluginDetection::DetectedDisabled, + _ => {} + } + return match plugin_enabled(&value) { + Some(true) => RelayPluginDetection::DetectedEnabled, + Some(false) => RelayPluginDetection::DetectedDisabled, + None => RelayPluginDetection::Detected, + }; + } + let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase(); + if stderr.contains("not found") + || stderr.contains("unknown plugin") + || stderr.contains("no plugin") + { + RelayPluginDetection::NotDetected + } else { + RelayPluginDetection::Unknown + } +} + +fn plugin_status(value: &Value) -> Option<&str> { + value + .get("status") + .and_then(Value::as_str) + .or_else(|| value.pointer("/plugin/status").and_then(Value::as_str)) + .or_else(|| value.pointer("/record/status").and_then(Value::as_str)) +} + +fn plugin_enabled(value: &Value) -> Option { + value + .get("enabled") + .and_then(Value::as_bool) + .or_else(|| value.pointer("/plugin/enabled").and_then(Value::as_bool)) + .or_else(|| value.pointer("/record/enabled").and_then(Value::as_bool)) +} + +fn probe_auth_profiles( + argv: &[String], + source_path: &Path, + config: Option<&Value>, + family: ProviderFamily, +) -> AuthProfileEvidence { + let mut aggregate = None; + for agent in auth_probe_agents(argv, config) { + let evidence = probe_one_auth_profile_set(argv, source_path, family, agent.as_deref()); + aggregate = Some(match aggregate { + None => evidence, + Some(current) => merge_auth_profile_evidence(current, evidence), + }); + } + aggregate.unwrap_or(AuthProfileEvidence::Unknown) +} + +fn probe_one_auth_profile_set( + argv: &[String], + source_path: &Path, + family: ProviderFamily, + agent: Option<&str>, +) -> AuthProfileEvidence { + let mut args = vec!["models".into(), "auth".into()]; + if let Some(agent) = agent { + args.extend(["--agent".into(), agent.to_string()]); + } + args.push("list".into()); + args.extend(["--provider".into(), family.id().into(), "--json".into()]); + let Some(output) = run_read_only_probe(argv, source_path, &args) else { + return AuthProfileEvidence::Unknown; + }; + if !output.status.success() { + return AuthProfileEvidence::Unknown; + } + let Ok(value) = serde_json::from_slice::(&output.stdout) else { + return AuthProfileEvidence::Unknown; + }; + let Some(profiles) = value.get("profiles").and_then(Value::as_array) else { + return AuthProfileEvidence::Unknown; + }; + if profiles.is_empty() { + return AuthProfileEvidence::None; + } + if profiles + .iter() + .all(|profile| profile.get("type").and_then(Value::as_str) == Some("api_key")) + { + AuthProfileEvidence::ApiKeyOnly + } else { + AuthProfileEvidence::NonApiKeyOrMixed + } +} + +fn auth_probe_agents(argv: &[String], config: Option<&Value>) -> Vec> { + if let Some(agent) = selected_agent_id(argv) { + return vec![Some(agent.to_string())]; + } + // Session keys and workspace selection can choose a non-default agent without an explicit + // `--agent`. In that ambiguous case, aggregate every configured auth store rather than + // redirecting globally based only on the default agent. + let mut agents = vec![None]; + if let Some(configured) = config + .and_then(|config| config.pointer("/agents/list")) + .and_then(Value::as_array) + { + for agent in configured { + let Some(id) = agent.get("id").and_then(Value::as_str) else { + continue; + }; + if !id.trim().is_empty() && !agents.iter().flatten().any(|existing| existing == id) { + agents.push(Some(id.to_string())); + } + } + } + agents +} + +fn merge_auth_profile_evidence( + left: AuthProfileEvidence, + right: AuthProfileEvidence, +) -> AuthProfileEvidence { + use AuthProfileEvidence::{ApiKeyOnly, ApiKeyOrNone, NonApiKeyOrMixed, None, Unknown}; + match (left, right) { + (Unknown, _) | (_, Unknown) => Unknown, + (NonApiKeyOrMixed, _) | (_, NonApiKeyOrMixed) => NonApiKeyOrMixed, + (ApiKeyOnly, ApiKeyOnly) => ApiKeyOnly, + (None, None) => None, + (ApiKeyOrNone, ApiKeyOnly | None | ApiKeyOrNone) + | (ApiKeyOnly | None, ApiKeyOrNone) + | (ApiKeyOnly, None) + | (None, ApiKeyOnly) => ApiKeyOrNone, + } +} + +fn selected_agent_id(argv: &[String]) -> Option<&str> { + let executable = openclaw_executable_index(argv)?; + let (command_index, command) = first_openclaw_positional(argv, executable)?; + if command != "agent" { + return None; + } + let mut index = command_index + 1; + while index < argv.len() { + match argv[index].as_str() { + "--agent" => return argv.get(index + 1).map(String::as_str), + value if value.starts_with("--agent=") => { + return Some(value.trim_start_matches("--agent=")); + } + _ => index += 1, + } + } + None +} + +fn run_read_only_probe(argv: &[String], source_path: &Path, args: &[String]) -> Option { + let executable = openclaw_executable_index(argv)?; + let mut command = Command::new(argv.first()?); + if executable > 0 { + command.args(&argv[1..=executable]); + } + command + .args(args) + .env(OPENCLAW_CONFIG_PATH, source_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if std::env::var_os("OPENCLAW_STATE_DIR").is_none() + && let Ok(state_dir) = probe_state_dir(argv) + { + command.env("OPENCLAW_STATE_DIR", state_dir); + } + command.output().ok() +} + +fn probe_state_dir(argv: &[String]) -> Result { + if let Some(path) = nonempty_env_os("OPENCLAW_STATE_DIR") { + return resolve_openclaw_path(path); + } + let home = effective_home()?; + if let Some(profile) = command_profile(argv)? { + return Ok(home.join(format!(".openclaw-{profile}"))); + } + let primary = home.join(".openclaw"); + if primary.exists() { + return Ok(primary); + } + let legacy = home.join(".clawdbot"); + if legacy.exists() { + return Ok(legacy); + } + Ok(primary) +} + +fn plugin_detection_note(detection: RelayPluginDetection) -> String { + match detection { + RelayPluginDetection::DetectedEnabled => format!( + "{RELAY_OPENCLAW_PACKAGE} detected; hook and tool telemetry remain owned by the embedded OpenClaw plugin" + ), + RelayPluginDetection::DetectedDisabled => format!( + "{RELAY_OPENCLAW_PACKAGE} is installed but disabled; Relay did not change OpenClaw plugin state" + ), + RelayPluginDetection::DetectedError => format!( + "{RELAY_OPENCLAW_PACKAGE} is installed but failed to load; Relay did not change OpenClaw plugin state" + ), + RelayPluginDetection::Detected => format!( + "{RELAY_OPENCLAW_PACKAGE} is installed; Relay did not change OpenClaw plugin state" + ), + RelayPluginDetection::NotDetected => format!( + "{RELAY_OPENCLAW_PACKAGE} not detected; Relay will not install it automatically, so this run has provider routing but no embedded hook/tool forwarding" + ), + RelayPluginDetection::Unknown => format!( + "could not determine whether {RELAY_OPENCLAW_PACKAGE} is installed; Relay did not modify OpenClaw plugin state" + ), + } +} + +fn temporary_overlay_path(source_path: &Path) -> Result { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| CliError::Launch(error.to_string()))? + .as_nanos(); + let parent = source_path.parent().ok_or_else(|| { + CliError::Launch(format!( + "OpenClaw config path {} has no parent directory", + source_path.display() + )) + })?; + std::fs::create_dir_all(parent)?; + Ok(parent.join(format!( + ".openclaw.nemo-relay-{}-{stamp}.json5", + std::process::id() + ))) +} + +fn nonempty_env_os(name: &str) -> Option { + std::env::var_os(name).filter(|value| { + let value = value.to_string_lossy(); + let value = value.trim(); + !value.is_empty() && value != "undefined" && value != "null" + }) +} + +#[cfg(test)] +#[path = "../tests/coverage/openclaw_tests.rs"] +mod tests; diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 9a717cc36..934b65913 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -270,6 +270,7 @@ fn ask_agents( CodingAgent::ClaudeCode, CodingAgent::Codex, CodingAgent::Hermes, + CodingAgent::Openclaw, ]; let labels: Vec = all_supported .iter() diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs index fe03003e7..61d95c61d 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/setup/model.rs @@ -91,6 +91,7 @@ pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> (CodingAgent::ClaudeCode, "claude"), (CodingAgent::Codex, "codex"), (CodingAgent::Hermes, "hermes"), + (CodingAgent::Openclaw, "openclaw"), ]; candidates .into_iter() @@ -409,6 +410,7 @@ pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { "claude" => Some(CodingAgent::ClaudeCode), "codex" => Some(CodingAgent::Codex), "hermes" => Some(CodingAgent::Hermes), + "openclaw" => Some(CodingAgent::Openclaw), _ => None, }; if let Some(agent) = agent { @@ -423,6 +425,7 @@ pub(super) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'stat CodingAgent::ClaudeCode => ("claude", "claude"), CodingAgent::Codex => ("codex", "codex"), CodingAgent::Hermes => ("hermes", "hermes"), + CodingAgent::Openclaw => ("openclaw", "openclaw"), } } diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index d1a3fc3d3..b07553498 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -295,6 +295,7 @@ fn agent_and_gateway_mode_arguments_are_stable() { assert_eq!(CodingAgent::ClaudeCode.hook_path(), "/hooks/claude-code"); assert_eq!(CodingAgent::Codex.hook_path(), "/hooks/codex"); assert_eq!(CodingAgent::Hermes.hook_path(), "/hooks/hermes"); + assert_eq!(CodingAgent::Openclaw.as_arg(), "openclaw"); assert_eq!(GatewayMode::HookOnly.as_arg(), "hook-only"); assert_eq!(GatewayMode::Passthrough.as_arg(), "passthrough"); assert_eq!(GatewayMode::Required.as_arg(), "required"); @@ -309,6 +310,10 @@ fn agent_inference_uses_executable_basename() { assert_eq!(CodingAgent::infer("codex"), Some(CodingAgent::Codex)); assert_eq!(CodingAgent::infer("cursor-agent"), None); assert_eq!(CodingAgent::infer("hermes"), Some(CodingAgent::Hermes)); + assert_eq!( + CodingAgent::infer("/opt/bin/openclaw"), + Some(CodingAgent::Openclaw) + ); assert_eq!(CodingAgent::infer("wrapper"), None); } @@ -335,6 +340,9 @@ command = "codex --approval-mode never" [agents.hermes] command = "hermes --yolo chat" + +[agents.openclaw] +command = "openclaw tui" "#, ) .unwrap(); @@ -367,6 +375,10 @@ command = "hermes --yolo chat" resolved.agents.hermes.command.as_deref(), Some("hermes --yolo chat") ); + assert_eq!( + resolved.agents.openclaw.command.as_deref(), + Some("openclaw tui") + ); } #[test] diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index 1615642cd..161ed8e30 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -227,6 +227,7 @@ 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"); + assert_eq!(default_command_for(CodingAgent::Openclaw), "openclaw"); let agents = AgentConfigs { codex: AgentCommandConfig { @@ -238,6 +239,33 @@ fn default_and_configured_command_helpers_cover_empty_and_all_agents() { assert!(configured_command(CodingAgent::Codex, &agents).is_none()); } +#[test] +fn resolves_configured_openclaw_command_and_inference() { + let agents = AgentConfigs { + openclaw: AgentCommandConfig { + command: Some("openclaw tui".into()), + hooks_path: None, + }, + ..AgentConfigs::default() + }; + let command = RunCommand { + agent: Some(CodingAgent::Openclaw), + config: None, + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config_path: None, + dry_run: true, + print: false, + command: vec![], + }; + + let (agent, argv) = resolve_agent_and_argv(&command, &agents).unwrap(); + + assert_eq!(agent, CodingAgent::Openclaw); + assert_eq!(argv, ["openclaw", "tui"]); +} + #[test] fn prepares_codex_config_overrides() { let resolved = ResolvedConfig { @@ -771,6 +799,7 @@ fn hermes_restore_reports_restore_and_temporary_removal_failures() { argv: vec![], env: vec![], temp_dirs: vec![], + temp_files: vec![], hermes_restore: Some(HermesRestore { path: temp.path().join("config.yaml"), backup_path: Some(temp.path().join("missing-backup.yaml")), @@ -788,6 +817,7 @@ fn hermes_restore_reports_restore_and_temporary_removal_failures() { argv: vec![], env: vec![], temp_dirs: vec![], + temp_files: vec![], hermes_restore: Some(HermesRestore { path: hooks_path, backup_path: None, @@ -800,6 +830,44 @@ fn hermes_restore_reports_restore_and_temporary_removal_failures() { assert!(error.contains("failed to remove temporary Hermes hooks")); } +#[test] +fn restore_removes_temporary_launcher_files() { + let temp = tempfile::tempdir().unwrap(); + let overlay = temp.path().join("overlay.json5"); + std::fs::write(&overlay, "{}").unwrap(); + let prepared = PreparedRun { + argv: vec![], + env: vec![], + temp_dirs: vec![], + temp_files: vec![overlay.clone()], + hermes_restore: None, + notes: vec![], + }; + + prepared.restore().unwrap(); + + assert!(!overlay.exists()); +} + +#[test] +fn prepared_run_drop_defensively_removes_temporary_launcher_files() { + let temp = tempfile::tempdir().unwrap(); + let overlay = temp.path().join("overlay.json5"); + std::fs::write(&overlay, "{}").unwrap(); + let prepared = PreparedRun { + argv: vec![], + env: vec![], + temp_dirs: vec![], + temp_files: vec![overlay.clone()], + hermes_restore: None, + notes: vec![], + }; + + drop(prepared); + + assert!(!overlay.exists()); +} + #[test] fn hook_backup_and_write_helpers_cover_missing_existing_and_toml_escaping() { let temp = tempfile::tempdir().unwrap(); @@ -1093,6 +1161,117 @@ async fn execute_live_run_restores_hermes_hooks_when_health_check_fails() { assert_eq!(std::fs::read_to_string(&hooks_path).unwrap(), original); } +#[tokio::test] +async fn execute_live_run_removes_overlay_when_child_spawn_fails() { + let temp = tempfile::tempdir().unwrap(); + let overlay = temp.path().join("openclaw-overlay.json5"); + std::fs::write(&overlay, "{}").unwrap(); + let prepared = PreparedRun { + argv: vec![temp.path().join("missing-openclaw").display().to_string()], + env: vec![], + temp_dirs: vec![], + temp_files: vec![overlay.clone()], + hermes_restore: None, + notes: vec![], + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let gateway_url = format!("http://{}", listener.local_addr().unwrap()); + + execute_live_run(listener, GatewayConfig::default(), &gateway_url, prepared) + .await + .unwrap_err(); + + assert!(!overlay.exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn execute_live_run_removes_overlay_after_launcher_shutdown_signal() { + let temp = tempfile::tempdir().unwrap(); + let overlay = temp.path().join("openclaw-overlay.json5"); + std::fs::write(&overlay, "{}").unwrap(); + let child = temp.path().join("openclaw"); + let started = temp.path().join("child-started"); + let stopped = temp.path().join("child-stopped"); + std::fs::write( + &child, + format!( + "#!/bin/sh\ntrap \"touch '{}'; exit 0\" TERM INT\ntouch '{}'\nwhile :; do sleep 1; done\n", + stopped.display(), + started.display(), + ), + ) + .unwrap(); + make_executable(&child); + let prepared = PreparedRun { + argv: vec![child.display().to_string()], + env: vec![], + temp_dirs: vec![], + temp_files: vec![overlay.clone()], + hermes_restore: None, + notes: vec![], + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let gateway_url = format!("http://{}", listener.local_addr().unwrap()); + let started_for_signal = started.clone(); + + let code = tokio::time::timeout( + Duration::from_secs(10), + execute_live_run_with_shutdown( + listener, + GatewayConfig::default(), + &gateway_url, + prepared, + async move { + loop { + if started_for_signal.exists() { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }, + ), + ) + .await + .expect("test child did not start and stop before timeout") + .unwrap(); + + assert_eq!(code, ExitCode::SUCCESS); + assert!(started.exists()); + assert!(stopped.exists()); + assert!(!overlay.exists()); +} + +#[tokio::test] +async fn execute_live_run_removes_overlay_when_shutdown_precedes_health() { + let temp = tempfile::tempdir().unwrap(); + let overlay = temp.path().join("openclaw-overlay.json5"); + std::fs::write(&overlay, "{}").unwrap(); + let prepared = PreparedRun { + argv: vec![temp.path().join("unused-openclaw").display().to_string()], + env: vec![], + temp_dirs: vec![], + temp_files: vec![overlay.clone()], + hermes_restore: None, + notes: vec![], + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let gateway_url = format!("http://{}", listener.local_addr().unwrap()); + + let code = execute_live_run_with_shutdown( + listener, + GatewayConfig::default(), + &gateway_url, + prepared, + std::future::ready(Ok(())), + ) + .await + .unwrap(); + + assert_eq!(code, ExitCode::FAILURE); + assert!(!overlay.exists()); +} + #[cfg(unix)] fn make_executable(path: &Path) { use std::os::unix::fs::PermissionsExt; diff --git a/crates/cli/tests/coverage/openclaw_tests.rs b/crates/cli/tests/coverage/openclaw_tests.rs new file mode 100644 index 000000000..aef25bef6 --- /dev/null +++ b/crates/cli/tests/coverage/openclaw_tests.rs @@ -0,0 +1,712 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use std::ffi::{OsStr, OsString}; + +struct EnvScope { + _guard: std::sync::MutexGuard<'static, ()>, + values: Vec<(OsString, Option)>, +} + +impl EnvScope { + fn provider_test(values: &[(&str, Option<&OsStr>)]) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let mut names = std::env::vars_os() + .map(|(name, _)| name) + .filter(|name| { + let name = name.to_string_lossy(); + name == "OPENAI_API_KEY" + || name == "OPENAI_API_KEYS" + || name == "OPENCLAW_LIVE_OPENAI_KEY" + || name.starts_with("OPENAI_API_KEY_") + || name == "ANTHROPIC_API_KEY" + || name == "ANTHROPIC_API_KEYS" + || name == "OPENCLAW_LIVE_ANTHROPIC_KEY" + || name.starts_with("ANTHROPIC_API_KEY_") + || name == OPENCLAW_CONFIG_PATH + || name == "OPENCLAW_INCLUDE_ROOTS" + || name == "OPENCLAW_STATE_DIR" + || name == "OPENCLAW_HOME" + }) + .collect::>(); + names.extend(values.iter().map(|(name, _)| OsString::from(name))); + names.sort(); + names.dedup(); + let previous = names + .iter() + .map(|name| (name.clone(), std::env::var_os(name))) + .collect::>(); + for name in &names { + unsafe { std::env::remove_var(name) }; + } + for (name, value) in values { + if let Some(value) = value { + unsafe { std::env::set_var(name, value) }; + } + } + Self { + _guard: guard, + values: previous, + } + } +} + +impl Drop for EnvScope { + fn drop(&mut self) { + for (name, value) in self.values.drain(..) { + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + } +} + +#[test] +fn forces_model_serving_commands_local_and_rejects_remote_or_detached_paths() { + let mut default = vec!["openclaw".into()]; + normalize_foreground_argv(&mut default).unwrap(); + assert_eq!(default, ["openclaw", "tui", "--local"]); + + let mut agent = vec![ + "openclaw".into(), + "agent".into(), + "--message".into(), + "hello".into(), + ]; + normalize_foreground_argv(&mut agent).unwrap(); + assert_eq!(agent[2], "--local"); + + let mut tui = vec![ + "npx".into(), + "openclaw".into(), + "tui".into(), + "--message".into(), + "hi".into(), + ]; + normalize_foreground_argv(&mut tui).unwrap(); + assert_eq!(tui[3], "--local"); + + let mut foreground = vec!["openclaw".into(), "gateway".into(), "run".into()]; + normalize_foreground_argv(&mut foreground).unwrap(); + assert_eq!(foreground, ["openclaw", "gateway", "run"]); + + let mut message_named_openclaw = vec![ + "openclaw".into(), + "agent".into(), + "--message".into(), + "openclaw".into(), + "--json".into(), + ]; + normalize_foreground_argv(&mut message_named_openclaw).unwrap(); + assert_eq!(message_named_openclaw[2], "--local"); + + let mut root_option = vec![ + "openclaw".into(), + "--log-level".into(), + "debug".into(), + "agent".into(), + ]; + normalize_foreground_argv(&mut root_option).unwrap(); + assert_eq!(root_option[4], "--local"); + + let remote = normalize_foreground_argv(&mut vec![ + "openclaw".into(), + "tui".into(), + "--url=ws://remote.example".into(), + ]) + .unwrap_err() + .to_string(); + assert!(remote.contains("remote TUI")); + + let detached = normalize_foreground_argv(&mut vec![ + "openclaw".into(), + "gateway".into(), + "start".into(), + ]) + .unwrap_err() + .to_string(); + assert!(detached.contains("foreground `gateway run`")); + + let container = normalize_foreground_argv(&mut vec![ + "openclaw".into(), + "--container".into(), + "sandbox".into(), + "agent".into(), + ]) + .unwrap_err() + .to_string(); + assert!(container.contains("host-side temporary provider overlay")); + + let mut profiled = vec!["openclaw".into(), "--profile".into(), "work".into()]; + normalize_foreground_argv(&mut profiled).unwrap(); + assert_eq!( + profiled, + ["openclaw", "--profile", "work", "tui", "--local"] + ); + + let mut command_named_profile = vec![ + "openclaw".into(), + "--profile".into(), + "agent".into(), + "tui".into(), + ]; + normalize_foreground_argv(&mut command_named_profile).unwrap(); + assert_eq!( + command_named_profile, + ["openclaw", "--profile", "agent", "tui", "--local"] + ); + assert_eq!( + command_profile(&command_named_profile).unwrap().as_deref(), + Some("agent") + ); + + let admin = normalize_foreground_argv(&mut vec![ + "openclaw".into(), + "plugins".into(), + "enable".into(), + "nemo-relay".into(), + ]) + .unwrap_err() + .to_string(); + assert!(admin.contains("run configuration, plugin, model-management")); +} + +#[test] +fn resolves_profile_config_after_environment_precedence() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::provider_test(&[("OPENCLAW_HOME", Some(temp.path().as_os_str()))]); + + assert_eq!( + resolve_config_path(&["openclaw".into(), "--profile=work".into()]).unwrap(), + temp.path().join(".openclaw-work/openclaw.json") + ); + assert_eq!( + resolve_config_path(&["openclaw".into(), "--dev".into()]).unwrap(), + temp.path().join(".openclaw-dev/openclaw.json") + ); +} + +#[test] +fn routes_anthropic_and_openai_completions_with_original_upstreams() { + let _env = EnvScope::provider_test(&[]); + let config: Value = json5::from_str( + r#"{ + // Both providers are explicitly API-key-backed. + models: { providers: { + anthropic: { + baseUrl: "https://anthropic.internal.example", + api: "anthropic-messages", + apiKey: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" }, + }, + openai: { + baseUrl: "https://openai.internal.example/v1", + api: "openai-completions", + auth: "api-key", + }, + } }, + }"#, + ) + .unwrap(); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43123"); + + assert_eq!( + plan.anthropic_upstream.as_deref(), + Some("https://anthropic.internal.example") + ); + assert_eq!( + plan.openai_upstream.as_deref(), + Some("https://openai.internal.example/v1") + ); + assert_eq!( + plan.provider_overrides["anthropic"]["baseUrl"], + "http://127.0.0.1:43123" + ); + assert_eq!( + plan.provider_overrides["anthropic"]["agentRuntime"]["id"], + "openclaw" + ); + assert_eq!( + plan.provider_overrides["openai"]["baseUrl"], + "http://127.0.0.1:43123/v1" + ); + assert_eq!( + plan.provider_overrides["openai"]["agentRuntime"]["id"], + "openclaw" + ); + assert!( + plan.notes + .iter() + .any(|note| note.contains("openai-completions")) + ); + assert!( + plan.notes + .iter() + .any(|note| note.contains("anthropic-messages")) + ); +} + +#[test] +fn routes_openai_responses_and_uses_canonical_default_endpoint() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "models": {"providers": {"openai": {"api": "openai-responses"}}} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43124"); + + assert_eq!( + plan.openai_upstream.as_deref(), + Some(DEFAULT_OPENAI_BASE_URL) + ); + assert_eq!( + plan.provider_overrides["openai"]["baseUrl"], + "http://127.0.0.1:43124/v1" + ); + assert!( + plan.notes + .iter() + .any(|note| note.contains("openai-responses")) + ); +} + +#[test] +fn leaves_custom_unsupported_oauth_and_model_override_providers_unchanged() { + let _env = EnvScope::provider_test(&[]); + let config = json!({ + "models": {"providers": { + "custom-openai": { + "baseUrl": "https://custom.example/v1", + "api": "openai-completions", + "apiKey": "secret" + }, + "openai": { + "api": "openai-responses", + "auth": "oauth" + }, + "anthropic": { + "api": "anthropic-messages", + "apiKey": "secret", + "models": [{"id": "claude", "baseUrl": "https://model.example"}] + } + }} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43125"); + + assert!(plan.provider_overrides.is_empty()); + assert!(plan.openai_upstream.is_none()); + assert!(plan.anthropic_upstream.is_none()); + assert!(plan.notes.iter().any(|note| note.contains("custom-openai"))); + assert!( + plan.notes + .iter() + .any(|note| note.contains("explicit authentication mode is not API-key based")) + ); + assert!(plan.notes.iter().any(|note| note.contains("model-level"))); +} + +#[test] +fn provider_includes_fail_open_without_guessing_an_upstream() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "models": {"providers": {"openai": {"$include": "./openai-provider.json5"}}} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43126"); + + assert!(plan.provider_overrides.is_empty()); + assert!( + plan.notes + .iter() + .any(|note| note.contains("will not guess")) + ); + + let unrelated = json!({ + "plugins": {"$include": "./plugins.json5"}, + "models": {"providers": {"openai": {"api": "openai-responses"}}} + }); + let plan = build_routing_plan(Some(&unrelated), "http://127.0.0.1:43126"); + assert!(plan.provider_overrides.contains_key("openai")); +} + +#[test] +fn explicit_non_http_agent_runtime_is_not_claimed_as_routed() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "models": {"providers": {"openai": { + "api": "openai-responses", + "agentRuntime": {"id": "codex"} + }}} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43126"); + + assert!(plan.provider_overrides.get("openai").is_none()); + assert!(plan.openai_upstream.is_none()); + assert!( + plan.notes + .iter() + .any(|note| note.contains("direct OpenClaw HTTP runtime")) + ); +} + +#[test] +fn substituted_provider_endpoint_is_not_copied_unresolved_into_relay() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "models": {"providers": {"openai": { + "api": "openai-responses", + "baseUrl": "${OPENAI_BASE}/v1" + }}} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43126"); + + assert!(plan.provider_overrides.get("openai").is_none()); + assert!(plan.openai_upstream.is_none()); + assert!( + plan.notes + .iter() + .any(|note| note.contains("environment-substituted baseUrl")) + ); +} + +#[test] +fn agent_model_runtime_policy_is_not_overridden_or_claimed_as_routed() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "agents": { + "defaults": {"models": { + "openai/gpt-5.5": {"agentRuntime": {"id": "codex"}} + }}, + "list": [{ + "id": "main", + "models": { + "anthropic/*": {"agentRuntime": {"id": "claude-cli"}} + } + }] + }, + "models": {"providers": { + "openai": {"api": "openai-responses"}, + "anthropic": {"api": "anthropic-messages", "apiKey": "secret"} + }} + }); + + let plan = build_routing_plan(Some(&config), "http://127.0.0.1:43126"); + + assert!(plan.provider_overrides.is_empty()); + assert!(plan.openai_upstream.is_none()); + assert!(plan.anthropic_upstream.is_none()); + assert_eq!( + plan.notes + .iter() + .filter(|note| note.contains("agent model policy")) + .count(), + 2 + ); +} + +#[cfg(unix)] +#[test] +fn temporary_overlay_preserves_source_config_metadata_and_explicit_relay_upstream() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("openclaw.json5"); + let original = r#"{ + // This comment and formatting must remain byte-for-byte unchanged. + agents: { defaults: { model: { primary: "openai/gpt-test" } } }, + models: { providers: { openai: { + baseUrl: "https://source-upstream.example/v1", + api: "openai-responses", + apiKey: "configured-secret", + } } }, + } +"#; + std::fs::write(&source, original).unwrap(); + let _env = EnvScope::provider_test(&[(OPENCLAW_CONFIG_PATH, Some(source.as_os_str()))]); + let executable = temp.path().join("openclaw"); + std::fs::write( + &executable, + r#"#!/bin/sh +case "$*" in + *"models auth list --provider openai --json"*|*"models auth list --provider anthropic --json"*) + printf '%s' '{"profiles":[]}' + ;; + *) exit 1 ;; +esac +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + let mut argv = vec![ + executable.display().to_string(), + "agent".into(), + "--message".into(), + "hello".into(), + ]; + let mut gateway = GatewayConfig { + openai_base_url: "https://explicit-relay-upstream.example/v1".into(), + metadata: Some(json!({"team": "relay"})), + ..GatewayConfig::default() + }; + + let prepared = prepare( + &mut argv, + "http://127.0.0.1:43127", + &mut gateway, + ExplicitUpstreams { + openai: true, + anthropic: false, + }, + false, + ) + .unwrap(); + + assert_eq!(std::fs::read_to_string(&source).unwrap(), original); + assert_eq!( + gateway.openai_base_url, + "https://explicit-relay-upstream.example/v1" + ); + assert_eq!( + gateway.metadata.as_ref().unwrap()["nemo_relay_invocation"]["agent"], + "openclaw" + ); + assert_eq!(gateway.metadata.as_ref().unwrap()["team"], "relay"); + assert_eq!(argv[2], "--local"); + + let overlay_path = prepared + .env + .iter() + .find_map(|(name, value)| (name == OPENCLAW_CONFIG_PATH).then(|| PathBuf::from(value))) + .unwrap(); + let overlay: Value = + serde_json::from_str(&std::fs::read_to_string(&overlay_path).unwrap()).unwrap(); + assert_eq!(overlay["$include"], source.display().to_string()); + assert_eq!( + overlay["models"]["providers"]["openai"]["baseUrl"], + "http://127.0.0.1:43127/v1" + ); + assert_eq!(overlay_path.parent(), source.parent()); + std::fs::remove_file(&prepared.temp_files[0]).unwrap(); + assert!(!overlay_path.exists()); + assert!(source.exists()); +} + +#[cfg(unix)] +#[test] +fn read_only_probes_detect_plugin_state_and_deterministic_auth_profiles() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("openclaw.json5"); + std::fs::write(&source, "{}").unwrap(); + let executable = temp.path().join("openclaw"); + std::fs::write( + &executable, + r#"#!/bin/sh +case "$*" in + "plugins inspect nemo-relay --json") + printf '%s' '{"enabled":false}' + ;; + *"models auth --agent work list --provider openai --json"*) + printf '%s' '{"profiles":[{"id":"openai:work","type":"api_key"}]}' + ;; + *"models auth list --provider openai --json"*) + printf '%s' '{"profiles":[{"id":"openai:one","type":"api_key"},{"id":"openai:two","type":"api_key"}]}' + ;; + *"models auth list --provider anthropic --json"*) + printf '%s' '{"profiles":[{"id":"anthropic:key","type":"api_key"},{"id":"anthropic:oauth","type":"oauth"}]}' + ;; + *) + exit 2 + ;; +esac +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + let argv = vec![executable.display().to_string()]; + + assert_eq!( + probe_relay_plugin(&argv, &source), + RelayPluginDetection::DetectedDisabled + ); + assert_eq!( + probe_auth_profiles(&argv, &source, None, ProviderFamily::OpenAi), + AuthProfileEvidence::ApiKeyOnly + ); + assert_eq!( + probe_auth_profiles(&argv, &source, None, ProviderFamily::Anthropic), + AuthProfileEvidence::NonApiKeyOrMixed + ); + + let selected = vec![ + executable.display().to_string(), + "agent".into(), + "--agent".into(), + "work".into(), + ]; + assert_eq!(selected_agent_id(&selected), Some("work")); + assert_eq!( + probe_auth_profiles(&selected, &source, None, ProviderFamily::OpenAi), + AuthProfileEvidence::ApiKeyOnly + ); + + std::fs::write( + &executable, + "#!/bin/sh\nprintf '%s' '{\"plugin\":{\"enabled\":true,\"status\":\"error\"}}'\n", + ) + .unwrap(); + assert_eq!( + probe_relay_plugin(&argv, &source), + RelayPluginDetection::DetectedError + ); +} + +#[cfg(unix)] +#[test] +fn ambiguous_agent_selection_probes_every_configured_auth_store() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("openclaw.json5"); + std::fs::write(&source, "{}").unwrap(); + let executable = temp.path().join("openclaw"); + std::fs::write( + &executable, + r#"#!/bin/sh +case "$*" in + "models auth list --provider openai --json"|*"models auth --agent work list --provider openai --json"*) + printf '%s' '{"profiles":[{"type":"api_key"}]}' + ;; + *"models auth --agent personal list --provider openai --json"*) + printf '%s' '{"profiles":[]}' + ;; + *) + exit 2 + ;; +esac +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + let argv = vec![ + executable.display().to_string(), + "agent".into(), + "--session-key".into(), + "agent:personal:main".into(), + ]; + let config = json!({"agents": {"list": [{"id": "work"}, {"id": "personal"}]}}); + + assert_eq!( + probe_auth_profiles(&argv, &source, Some(&config), ProviderFamily::OpenAi), + AuthProfileEvidence::ApiKeyOrNone + ); +} + +#[test] +fn trusted_config_and_global_dotenv_api_keys_enable_profileless_routes() { + assert!(!nonempty_dotenv_value(" # comment")); + assert!(!nonempty_dotenv_value("\"\"")); + let temp = tempfile::tempdir().unwrap(); + let state = temp.path().join("state"); + std::fs::create_dir_all(&state).unwrap(); + std::fs::write( + state.join(".env"), + "# values are never copied into diagnostics\nANTHROPIC_API_KEY=dotenv-secret\n", + ) + .unwrap(); + let _env = EnvScope::provider_test(&[("OPENCLAW_STATE_DIR", Some(state.as_os_str()))]); + let argv = vec!["openclaw".to_string(), "agent".into()]; + let config = json!({ + "env": {"vars": {"OPENAI_API_KEY": "config-secret"}}, + "models": {"providers": { + "openai": {"api": "openai-responses"}, + "anthropic": {"api": "anthropic-messages"} + }} + }); + let trusted = trusted_api_key_sources(&argv, Some(&config)); + + assert!(trusted.openai); + assert!(trusted.anthropic); + let plan = build_routing_plan_with_evidence( + Some(&config), + "http://127.0.0.1:43129", + AuthProfileEvidenceSet::default(), + trusted, + ); + assert!(plan.provider_overrides.contains_key("openai")); + assert!(plan.provider_overrides.contains_key("anthropic")); + assert!(plan.notes.iter().all(|note| !note.contains("secret"))); +} + +#[test] +fn ambiguous_auth_profile_types_prevent_provider_redirection() { + let _env = EnvScope::provider_test(&[("OPENAI_API_KEY", Some(OsStr::new("test-key")))]); + let config = json!({ + "models": {"providers": {"openai": {"api": "openai-responses"}}} + }); + let plan = build_routing_plan_with_auth( + Some(&config), + "http://127.0.0.1:43128", + AuthProfileEvidenceSet { + openai: AuthProfileEvidence::NonApiKeyOrMixed, + anthropic: AuthProfileEvidence::None, + }, + ); + + assert!(plan.provider_overrides.is_empty()); + assert!(plan.notes.iter().any(|note| note.contains("ambiguous"))); + + let explicit_but_unverified = json!({ + "models": {"providers": {"openai": { + "api": "openai-responses", + "apiKey": "configured-but-unverified" + }}} + }); + let plan = build_routing_plan_with_auth( + Some(&explicit_but_unverified), + "http://127.0.0.1:43128", + AuthProfileEvidenceSet { + openai: AuthProfileEvidence::Unknown, + anthropic: AuthProfileEvidence::None, + }, + ); + assert!(plan.provider_overrides.is_empty()); + assert!( + plan.notes + .iter() + .any(|note| note.contains("could not be verified")) + ); +} + +#[test] +fn invocation_metadata_preserves_non_object_user_metadata() { + let mut gateway = GatewayConfig { + metadata: Some(json!(["user", "metadata"])), + ..GatewayConfig::default() + }; + + add_invocation_metadata(&mut gateway); + + let metadata = gateway.metadata.unwrap(); + assert_eq!( + metadata["nemo_relay_original_metadata"], + json!(["user", "metadata"]) + ); + assert_eq!(metadata["nemo_relay_invocation"]["agent"], "openclaw"); +} diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index aed26f987..fc30a22ee 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -91,9 +91,9 @@ impl Drop for EnvScope { fn detect_installed_agents_finds_binaries_on_path() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().unwrap(); - // Drop stub binaries for two of the three supported agents — confirming detection picks up + // Drop stub binaries for three supported agents — confirming detection picks up // only the ones present and ignores the others. - for exec in ["claude", "hermes"] { + for exec in ["claude", "hermes", "openclaw"] { let path = temp.path().join(exec); std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); @@ -105,6 +105,7 @@ fn detect_installed_agents_finds_binaries_on_path() { let detected = detect_installed_agents_in(Some(temp.path().as_os_str())); assert!(detected.contains(&CodingAgent::ClaudeCode)); assert!(detected.contains(&CodingAgent::Hermes)); + assert!(detected.contains(&CodingAgent::Openclaw)); assert!(!detected.contains(&CodingAgent::Codex)); } @@ -151,7 +152,11 @@ fn build_config_skips_empty_sections_when_no_backends_selected() { fn build_config_emits_agents_block_with_user_facing_keys() { let answers = SetupAnswers { scope: ConfigScope::Project, - agents: vec![CodingAgent::ClaudeCode, CodingAgent::Codex], + agents: vec![ + CodingAgent::ClaudeCode, + CodingAgent::Codex, + CodingAgent::Openclaw, + ], hermes_hooks_path: None, }; @@ -163,6 +168,8 @@ fn build_config_emits_agents_block_with_user_facing_keys() { assert!(rendered.contains(r#"command = "claude""#)); assert!(rendered.contains("[agents.codex]")); assert!(rendered.contains(r#"command = "codex""#)); + assert!(rendered.contains("[agents.openclaw]")); + assert!(rendered.contains(r#"command = "openclaw""#)); } #[test] From 0111d48a1e7cc786b73554c91742145fa43dff91 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 7 Jul 2026 08:18:55 -0600 Subject: [PATCH 08/10] chore: refresh Rust attributions Signed-off-by: Bryan Bednarski --- ATTRIBUTIONS-Rust.md | 1163 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 1099 insertions(+), 64 deletions(-) diff --git a/ATTRIBUTIONS-Rust.md b/ATTRIBUTIONS-Rust.md index 4bdfa809e..6f5b67fa4 100644 --- a/ATTRIBUTIONS-Rust.md +++ b/ATTRIBUTIONS-Rust.md @@ -20911,6 +20911,27 @@ limitations under the License. ``` +## json5 - 0.4.1 +**Repository URL**: https://github.com/callum-oakley/json5-rs +**License Type(s)**: ISC +### License: https://spdx.org/licenses/ISC.html +``` +Copyright 2018 Callum Oakley + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +``` + ## jsonschema - 0.46.8 **Repository URL**: https://github.com/Stranger6667/jsonschema **License Type(s)**: MIT @@ -22479,9 +22500,8 @@ SOFTWARE. ## md-5 - 0.10.6 **Repository URL**: https://github.com/RustCrypto/hashes -**License Type(s)**: MIT OR Apache-2.0 -### License: https://spdx.org/licenses/ -### License File: LICENSE-APACHE +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html ``` Apache License Version 2.0, January 2004 @@ -22684,37 +22704,7 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -``` - -### License File: LICENSE-MIT -``` -Copyright (c) 2006-2009 Graydon Hoare -Copyright (c) 2009-2013 Mozilla Foundation -Copyright (c) 2016 Artyom Pavlov - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. ``` ## memchr - 2.8.0 @@ -26249,38 +26239,874 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` +``` + +## outref - 0.5.2 +**Repository URL**: https://github.com/Nugine/outref +**License Type(s)**: MIT +### License: https://spdx.org/licenses/MIT.html +``` +MIT License + +Copyright (c) 2022 Nugine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## parking - 2.2.1 +**Repository URL**: https://github.com/smol-rs/parking +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + +## parking_lot - 0.12.5 +**Repository URL**: https://github.com/Amanieu/parking_lot +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + +## parking_lot_core - 0.9.12 +**Repository URL**: https://github.com/Amanieu/parking_lot +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + +## percent-encoding - 2.3.2 +**Repository URL**: https://github.com/servo/rust-url/ +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. -## outref - 0.5.2 -**Repository URL**: https://github.com/Nugine/outref -**License Type(s)**: MIT -### License: https://spdx.org/licenses/MIT.html -``` -MIT License +Copyright [yyyy] [name of copyright owner] -Copyright (c) 2022 Nugine +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + http://www.apache.org/licenses/LICENSE-2.0 -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. ``` -## parking - 2.2.1 -**Repository URL**: https://github.com/smol-rs/parking +## pest - 2.8.7 +**Repository URL**: https://github.com/pest-parser/pest **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -26488,8 +27314,8 @@ limitations under the License. ``` -## parking_lot - 0.12.5 -**Repository URL**: https://github.com/Amanieu/parking_lot +## pest_derive - 2.8.7 +**Repository URL**: https://github.com/pest-parser/pest **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -26697,8 +27523,8 @@ limitations under the License. ``` -## parking_lot_core - 0.9.12 -**Repository URL**: https://github.com/Amanieu/parking_lot +## pest_generator - 2.8.7 +**Repository URL**: https://github.com/pest-parser/pest **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -26906,8 +27732,8 @@ limitations under the License. ``` -## percent-encoding - 2.3.2 -**Repository URL**: https://github.com/servo/rust-url/ +## pest_meta - 2.8.7 +**Repository URL**: https://github.com/pest-parser/pest **License Type(s)**: Apache-2.0 ### License: https://spdx.org/licenses/Apache-2.0.html ``` @@ -41783,6 +42609,215 @@ See the License for the specific language governing permissions and limitations under the License. ``` +## ucd-trie - 0.1.7 +**Repository URL**: https://github.com/BurntSushi/ucd-generate +**License Type(s)**: Apache-2.0 +### License: https://spdx.org/licenses/Apache-2.0.html +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + ## unicase - 2.9.0 **Repository URL**: https://github.com/seanmonstar/unicase **License Type(s)**: Apache-2.0 From 3fe5ff38d57a59b7a36479cf009067e9b49c5261 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 7 Jul 2026 08:27:57 -0600 Subject: [PATCH 09/10] chore: align Rust attributions with CI Signed-off-by: Bryan Bednarski --- ATTRIBUTIONS-Rust.md | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/ATTRIBUTIONS-Rust.md b/ATTRIBUTIONS-Rust.md index 6f5b67fa4..6f139889f 100644 --- a/ATTRIBUTIONS-Rust.md +++ b/ATTRIBUTIONS-Rust.md @@ -22500,8 +22500,9 @@ SOFTWARE. ## md-5 - 0.10.6 **Repository URL**: https://github.com/RustCrypto/hashes -**License Type(s)**: Apache-2.0 -### License: https://spdx.org/licenses/Apache-2.0.html +**License Type(s)**: MIT OR Apache-2.0 +### License: https://spdx.org/licenses/ +### License File: LICENSE-APACHE ``` Apache License Version 2.0, January 2004 @@ -22704,7 +22705,37 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +``` + +### License File: LICENSE-MIT +``` +Copyright (c) 2006-2009 Graydon Hoare +Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2016 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. ``` ## memchr - 2.8.0 From c0d9b4bf4414e47b60604dbe41e8672eb3c6f040 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Tue, 7 Jul 2026 09:36:03 -0600 Subject: [PATCH 10/10] refactor(cli): use one Relay runtime for OpenClaw Signed-off-by: Bryan Bednarski --- README.md | 11 +- crates/cli/README.md | 6 +- crates/cli/src/adapters/mod.rs | 13 +- crates/cli/src/adapters/openclaw.rs | 33 ++ crates/cli/src/config.rs | 21 +- crates/cli/src/doctor.rs | 38 +-- crates/cli/src/installer.rs | 9 +- crates/cli/src/launcher.rs | 10 +- crates/cli/src/main.rs | 4 +- crates/cli/src/model.rs | 2 + crates/cli/src/openclaw.rs | 330 +++++++++++++------- crates/cli/src/server.rs | 18 ++ crates/cli/src/setup.rs | 2 +- crates/cli/src/setup/model.rs | 6 +- crates/cli/tests/cli_tests.rs | 2 +- crates/cli/tests/coverage/adapters_tests.rs | 58 +++- crates/cli/tests/coverage/config_tests.rs | 5 +- crates/cli/tests/coverage/launcher_tests.rs | 6 +- crates/cli/tests/coverage/openclaw_tests.rs | 77 +++-- crates/cli/tests/coverage/server_tests.rs | 26 ++ crates/cli/tests/coverage/setup_tests.rs | 4 +- 21 files changed, 466 insertions(+), 215 deletions(-) create mode 100644 crates/cli/src/adapters/openclaw.rs diff --git a/README.md b/README.md index 3e4aaf6ed..1dc22195e 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,12 @@ provider is deterministically API-key-backed: nemo-relay openclaw -- agent --message "Summarize this repository." ``` -Relay uses a temporary JSON5 include overlay and removes it when the child or -launcher exits. Remote OpenClaw gateways, custom providers, and ambiguous -OAuth/token routes are left unchanged rather than being claimed as intercepted. +Relay uses a temporary JSON5 include overlay to inject a lightweight hook bridge +and provider routing, then removes both when the child or launcher exits. The +bridge forwards into the same CLI-owned Relay runtime as the provider gateway; +it does not load a second Relay instance. Remote OpenClaw gateways, custom +providers, and ambiguous OAuth/token routes are left unchanged rather than being +claimed as intercepted. Refer to the full [Quick Start CLI](https://docs.nvidia.com/nemo/relay/nemo-relay-cli/about) docs for more options. @@ -298,7 +301,7 @@ coverage. | Claude Code | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed LLM observability are supported. | | Codex | Yes | Yes | Partial | Hook activation is required; missing session-end behavior limits trajectory finalization and full optimization coverage. | | Hermes Agent | Yes | Yes | Partial | Hook forwarding, pre-tool blocking, and gateway-routed or hook-backed LLM observability are supported. | -| OpenClaw | Yes | Partial | Partial | Local or foreground API-key-backed Anthropic/OpenAI requests can be gateway-routed. Embedded hook/tool telemetry requires the separately installed OpenClaw plugin. | +| OpenClaw | Yes | Partial | Partial | The CLI wrapper injects hook forwarding and routes local or foreground API-key-backed Anthropic/OpenAI requests through one Relay runtime. | ### Public API Integrations diff --git a/crates/cli/README.md b/crates/cli/README.md index 612ea1819..e8af02aff 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -106,8 +106,10 @@ OpenClaw launches in an embedded local or foreground-gateway mode so the temporary provider overlay reaches the model-serving process. Relay routes deterministically API-key-backed Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses providers while leaving custom, remote, or ambiguous -provider paths unchanged. Relay detects the optional `nemo-relay-openclaw` -plugin but never installs or enables it automatically. +provider paths unchanged. The overlay also injects a temporary hook bridge that +forwards OpenClaw lifecycle events into the CLI gateway. The bridge has no Relay +runtime dependency, and any separately configured embedded Relay plugin is +disabled for the wrapped invocation so only the CLI-owned runtime is active. Use `run --dry-run` to inspect resolved config without spawning the agent: diff --git a/crates/cli/src/adapters/mod.rs b/crates/cli/src/adapters/mod.rs index ce60db69d..51dd09740 100644 --- a/crates/cli/src/adapters/mod.rs +++ b/crates/cli/src/adapters/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod claude_code; pub(crate) mod codex; pub(crate) mod hermes; +pub(crate) mod openclaw; use axum::http::HeaderMap; use serde_json::{Map, Value, json}; @@ -180,11 +181,13 @@ pub(crate) trait AgentPayloadExtractor { pub(super) struct ClaudeCodePayloadExtractor; pub(super) struct CodexPayloadExtractor; pub(super) struct HermesPayloadExtractor; +pub(super) struct OpenClawPayloadExtractor; pub(super) static CLAUDE_CODE_PAYLOAD_EXTRACTOR: ClaudeCodePayloadExtractor = ClaudeCodePayloadExtractor; pub(super) static CODEX_PAYLOAD_EXTRACTOR: CodexPayloadExtractor = CodexPayloadExtractor; pub(super) static HERMES_PAYLOAD_EXTRACTOR: HermesPayloadExtractor = HermesPayloadExtractor; +pub(super) static OPENCLAW_PAYLOAD_EXTRACTOR: OpenClawPayloadExtractor = OpenClawPayloadExtractor; /// Claude Code reports its native tool identifier as `tool_use_id`, so it uses /// a tool path set that prefers that key. Every other hook field matches the @@ -222,6 +225,14 @@ impl AgentPayloadExtractor for HermesPayloadExtractor { } } +/// OpenClaw's temporary CLI bridge supplies Relay-normalized field names and +/// must not inherit Claude Code's installed-mode session header. +impl AgentPayloadExtractor for OpenClawPayloadExtractor { + fn session_header_policy(&self) -> SessionHeaderPolicy { + SessionHeaderPolicy::RelayOnly + } +} + pub(crate) struct ToolPathSet { call_id: &'static [&'static [&'static str]], name: &'static [&'static [&'static str]], @@ -886,7 +897,7 @@ fn classify_primary( } else { match normalized.as_str() { "afteragentresponse" | "agentresponse" | "assistantresponse" | "afteragentthought" - | "prellmcall" | "postllmcall" | "stop" => { + | "llminput" | "prellmcall" | "postllmcall" | "stop" => { NormalizedEvent::LlmHint(common_llm_hint_event_with_fallback( payload, headers, diff --git a/crates/cli/src/adapters/openclaw.rs b/crates/cli/src/adapters/openclaw.rs new file mode 100644 index 000000000..de2aea2da --- /dev/null +++ b/crates/cli/src/adapters/openclaw.rs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use axum::http::HeaderMap; +use serde_json::{Value, json}; + +use crate::adapters::{AdapterOutcome, ClassificationRules, OPENCLAW_PAYLOAD_EXTRACTOR, classify}; +use crate::model::AgentKind; + +/// Normalizes events emitted by the temporary OpenClaw CLI bridge. +/// +/// The bridge is observational: model request mutation remains on the gateway's +/// managed LLM path, while these events provide session and tool correlation. +pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome { + let events = classify( + &payload, + headers, + &OPENCLAW_PAYLOAD_EXTRACTOR, + &ClassificationRules { + kind: AgentKind::OpenClaw, + agent_start: &["session_start"], + agent_end: &["session_end"], + subagent_start: &["subagent_start"], + subagent_end: &["subagent_end"], + tool_start: &["tool_start"], + tool_end: &["tool_end"], + }, + ); + AdapterOutcome { + events, + response: json!({}), + } +} diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 7aca0f23f..369c29e5c 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -71,16 +71,18 @@ pub(crate) enum Command { Hermes(EasyPathCommand), /// Run OpenClaw with observability (setup on first use) #[command( + name = "openclaw", long_about = "Run OpenClaw under an ephemeral NeMo Relay gateway. Relay creates a \ - temporary JSON5 overlay and routes API-key-backed canonical Anthropic and \ - OpenAI providers through the gateway without modifying OpenClaw's source \ - configuration, state, credentials, or plugin installation.", + temporary JSON5 overlay that injects hook forwarding and routes \ + API-key-backed canonical Anthropic and OpenAI providers through one \ + CLI-owned Relay runtime without modifying OpenClaw's source configuration, \ + state, credentials, or plugin installation.", after_help = "Examples:\n \ nemo-relay openclaw\n \ nemo-relay openclaw -- agent --agent main --message \"summarize this project\"\n \ nemo-relay openclaw -- gateway run" )] - Openclaw(EasyPathCommand), + OpenClaw(EasyPathCommand), /// Run the interactive setup (writes `.nemo-relay/config.toml`) Config(ConfigCommand), /// Create or edit plugin configuration (writes `plugins.toml`) @@ -538,7 +540,8 @@ pub(crate) enum CodingAgent { ClaudeCode, Codex, Hermes, - Openclaw, + #[value(name = "openclaw")] + OpenClaw, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] @@ -1369,9 +1372,7 @@ impl CodingAgent { Self::ClaudeCode => "/hooks/claude-code", Self::Codex => "/hooks/codex", Self::Hermes => "/hooks/hermes", - // OpenClaw hook/tool telemetry stays in the separately installed npm plugin. The - // hidden hook-forward command rejects this path before issuing an HTTP request. - Self::Openclaw => "/hooks/openclaw", + Self::OpenClaw => "/hooks/openclaw", } } @@ -1383,7 +1384,7 @@ impl CodingAgent { Self::ClaudeCode => "claude", Self::Codex => "codex", Self::Hermes => "hermes", - Self::Openclaw => "openclaw", + Self::OpenClaw => "openclaw", } } @@ -1398,7 +1399,7 @@ impl CodingAgent { "claude" | "claude-code" => Some(Self::ClaudeCode), "codex" => Some(Self::Codex), "hermes" | "hermes-agent" => Some(Self::Hermes), - "openclaw" | "openclaw.exe" => Some(Self::Openclaw), + "openclaw" | "openclaw.exe" => Some(Self::OpenClaw), _ => None, } } diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 897333d73..95662e99b 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -397,7 +397,7 @@ async fn collect_agents( (CodingAgent::ClaudeCode, "claude", "claude"), (CodingAgent::Codex, "codex", "codex"), (CodingAgent::Hermes, "hermes", "hermes"), - (CodingAgent::Openclaw, "openclaw", "openclaw"), + (CodingAgent::OpenClaw, "openclaw", "openclaw"), ]; let mut out = Vec::with_capacity(supported.len()); for (agent, display_name, default_exec) in supported { @@ -474,7 +474,7 @@ fn configured_agent_command(agent: CodingAgent, agents: &AgentConfigs) -> Option CodingAgent::ClaudeCode => agents.claude.command.as_ref(), CodingAgent::Codex => agents.codex.command.as_ref(), CodingAgent::Hermes => agents.hermes.command.as_ref(), - CodingAgent::Openclaw => agents.openclaw.command.as_ref(), + CodingAgent::OpenClaw => agents.openclaw.command.as_ref(), } } @@ -488,7 +488,7 @@ fn configured_agent_names(agents: &AgentConfigs) -> Vec { (CodingAgent::ClaudeCode, "claude"), (CodingAgent::Codex, "codex"), (CodingAgent::Hermes, "hermes"), - (CodingAgent::Openclaw, "openclaw"), + (CodingAgent::OpenClaw, "openclaw"), ] .into_iter() .filter_map(|(agent, name)| agent_configured(agent, agents).then_some(name.to_string())) @@ -536,34 +536,10 @@ fn hook_status( ), None => (Status::Info, "hooks: not configured".into()), }, - CodingAgent::Openclaw => match crate::openclaw::detect_relay_plugin() { - crate::openclaw::RelayPluginDetection::DetectedEnabled => ( - Status::Pass, - "hook/tool telemetry: nemo-relay-openclaw detected".into(), - ), - crate::openclaw::RelayPluginDetection::DetectedDisabled => ( - Status::Info, - "hook/tool telemetry: nemo-relay-openclaw is installed but disabled".into(), - ), - crate::openclaw::RelayPluginDetection::DetectedError => ( - Status::Warn, - "hook/tool telemetry: nemo-relay-openclaw is installed but failed to load".into(), - ), - crate::openclaw::RelayPluginDetection::Detected => ( - Status::Pass, - "hook/tool telemetry: nemo-relay-openclaw is installed".into(), - ), - crate::openclaw::RelayPluginDetection::NotDetected => ( - Status::Info, - "hook/tool telemetry: optional nemo-relay-openclaw plugin not detected; Relay does not install it automatically" - .into(), - ), - crate::openclaw::RelayPluginDetection::Unknown => ( - Status::Info, - "hook/tool telemetry: could not inspect optional nemo-relay-openclaw plugin state" - .into(), - ), - }, + CodingAgent::OpenClaw => ( + Status::Pass, + "hooks: temporary CLI bridge injected during run".into(), + ), } } diff --git a/crates/cli/src/installer.rs b/crates/cli/src/installer.rs index cfe4ded04..48960dd52 100644 --- a/crates/cli/src/installer.rs +++ b/crates/cli/src/installer.rs @@ -82,13 +82,6 @@ fn read_hook_payload() -> Result { // Builds the target gateway hook URL and applies fail-open/fail-closed behavior for missing // gateway discovery. Returning `Ok(None)` is the fail-open path used by default hook commands. fn hook_forward_url(command: &HookForwardCommand) -> Result, CliError> { - if command.agent == CodingAgent::Openclaw { - return Err(CliError::Install( - "OpenClaw hook and tool telemetry is provided by the optional \ - nemo-relay-openclaw npm plugin, not the Relay hook-forward endpoint" - .into(), - )); - } let Some(gateway_url) = resolve_hook_gateway_url( command.agent, command.gateway_url.clone(), @@ -208,7 +201,7 @@ pub(crate) fn generated_hooks(agent: CodingAgent, command: &str) -> Value { CodingAgent::ClaudeCode => claude_hooks(command), CodingAgent::Codex => codex_hooks(command), CodingAgent::Hermes => hermes_hooks(command), - CodingAgent::Openclaw => json!({"hooks": {}}), + CodingAgent::OpenClaw => json!({"hooks": {}}), } } diff --git a/crates/cli/src/launcher.rs b/crates/cli/src/launcher.rs index 09d2e6f18..9b8ff3968 100644 --- a/crates/cli/src/launcher.rs +++ b/crates/cli/src/launcher.rs @@ -124,7 +124,7 @@ impl TransparentRun { let gateway_url = format!("http://{address}"); resolved.gateway.bind = address; - let openclaw = if agent == CodingAgent::Openclaw { + let openclaw = if agent == CodingAgent::OpenClaw { Some(crate::openclaw::prepare( &mut argv, &gateway_url, @@ -312,7 +312,7 @@ const fn default_command_for(agent: CodingAgent) -> &'static str { CodingAgent::ClaudeCode => "claude", CodingAgent::Codex => "codex", CodingAgent::Hermes => "hermes", - CodingAgent::Openclaw => "openclaw", + CodingAgent::OpenClaw => "openclaw", } } @@ -338,7 +338,7 @@ fn configured_command(agent: CodingAgent, agents: &AgentConfigs) -> Option agents.claude.command.as_ref(), CodingAgent::Codex => agents.codex.command.as_ref(), CodingAgent::Hermes => agents.hermes.command.as_ref(), - CodingAgent::Openclaw => agents.openclaw.command.as_ref(), + CodingAgent::OpenClaw => agents.openclaw.command.as_ref(), }?; let argv: Vec<_> = command.split_whitespace().map(ToOwned::to_owned).collect(); (!argv.is_empty()).then_some(argv) @@ -477,7 +477,7 @@ impl PreparedRun { } // OpenClaw preparation happens before this generic constructor because it also // selects provider-specific upstreams on the gateway configuration. - CodingAgent::Openclaw => {} + CodingAgent::OpenClaw => {} } Ok(run) } @@ -689,7 +689,7 @@ impl PreparedRun { // OpenClaw routing notes visible on stderr because they are part of the interception // contract rather than decoration. if !std::io::IsTerminal::is_terminal(&std::io::stdout()) { - if agent == CodingAgent::Openclaw { + if agent == CodingAgent::OpenClaw { for note in &self.notes { eprintln!("nemo-relay openclaw: {note}"); } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a7d6ad838..9e5acea00 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -81,8 +81,8 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result { launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await } - Command::Openclaw(command) => { - launcher::easy_path(CodingAgent::Openclaw, command, Some(server)).await + Command::OpenClaw(command) => { + launcher::easy_path(CodingAgent::OpenClaw, command, Some(server)).await } Command::Config(command) => run_config(command).await, Command::Plugins(command) => run_plugins(command, server), diff --git a/crates/cli/src/model.rs b/crates/cli/src/model.rs index 4d7e8a792..48075fd64 100644 --- a/crates/cli/src/model.rs +++ b/crates/cli/src/model.rs @@ -8,6 +8,7 @@ pub(crate) enum AgentKind { Codex, ClaudeCode, Hermes, + OpenClaw, Gateway, } @@ -19,6 +20,7 @@ impl AgentKind { Self::Codex => "codex", Self::ClaudeCode => "claude-code", Self::Hermes => "hermes", + Self::OpenClaw => "openclaw", Self::Gateway => "gateway", } } diff --git a/crates/cli/src/openclaw.rs b/crates/cli/src/openclaw.rs index 9f1309e6a..c223c08ec 100644 --- a/crates/cli/src/openclaw.rs +++ b/crates/cli/src/openclaw.rs @@ -15,10 +15,116 @@ use crate::error::CliError; const OPENCLAW_CONFIG_PATH: &str = "OPENCLAW_CONFIG_PATH"; const RELAY_OPENCLAW_PLUGIN_ID: &str = "nemo-relay"; -const RELAY_OPENCLAW_PACKAGE: &str = "nemo-relay-openclaw"; +const RELAY_OPENCLAW_BRIDGE_ID: &str = "nemo-relay-cli-bridge"; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com"; +const BRIDGE_PACKAGE_JSON: &str = r#"{ + "name": "nemo-relay-cli-openclaw-bridge", + "version": "0.0.0", + "private": true, + "type": "module", + "openclaw": { + "extensions": ["./index.js"] + } +} +"#; + +const BRIDGE_MANIFEST_JSON: &str = r#"{ + "id": "nemo-relay-cli-bridge", + "name": "NeMo Relay CLI Bridge", + "description": "Temporary hook bridge owned by the NeMo Relay CLI wrapper.", + "activation": {"onStartup": true}, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} +"#; + +const BRIDGE_INDEX_JS: &str = r#"const HOOK_EVENT_NAMES = { + session_start: "session_start", + session_end: "session_end", + llm_input: "preLlmCall", + before_tool_call: "tool_start", + after_tool_call: "tool_end", + subagent_spawned: "subagent_start", + subagent_ended: "subagent_end", +}; + +let warned = false; + +function firstString(...values) { + return values.find((value) => typeof value === "string" && value.length > 0); +} + +function payloadFor(hookName, event = {}, ctx = {}) { + const sessionId = firstString( + event.sessionId, + ctx.sessionId, + event.sessionKey, + ctx.sessionKey, + event.requesterSessionKey, + ctx.requesterSessionKey, + event.runId, + ctx.runId, + ); + const toolCallId = firstString(event.toolCallId, ctx.toolCallId); + const toolName = firstString(event.toolName, ctx.toolName); + return { + ...event, + hook_event_name: HOOK_EVENT_NAMES[hookName] ?? hookName, + session_id: sessionId, + conversation_id: firstString(event.sessionKey, ctx.sessionKey, sessionId), + request_id: firstString(event.callId, event.requestId, event.runId, ctx.runId), + generation_id: firstString(event.callId, event.runId, ctx.runId), + agent_id: firstString(event.agentId, ctx.agentId), + subagent_id: firstString(event.childSessionKey, event.targetSessionKey), + tool_call_id: toolCallId, + tool_name: toolName, + tool_input: event.params, + tool_output: event.result, + status: event.error ? "error" : undefined, + }; +} + +async function forward(api, hookName, event, ctx) { + const baseUrl = process.env.NEMO_RELAY_GATEWAY_URL; + if (!baseUrl) return; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); + timer.unref?.(); + try { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/hooks/openclaw`, { + method: "POST", + headers: {"content-type": "application/json"}, + body: JSON.stringify(payloadFor(hookName, event, ctx)), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + } catch (error) { + if (!warned) { + warned = true; + api.logger.warn?.(`NeMo Relay CLI hook forwarding degraded: ${String(error)}`); + } + } finally { + clearTimeout(timer); + } +} + +export default { + id: "nemo-relay-cli-bridge", + name: "NeMo Relay CLI Bridge", + register(api) { + for (const hookName of Object.keys(HOOK_EVENT_NAMES)) { + api.on(hookName, (event, ctx) => forward(api, hookName, event, ctx)); + } + }, +}; +"#; + /// Records which Relay upstream flags were explicitly supplied by the caller. #[derive(Debug, Clone, Copy, Default)] pub(crate) struct ExplicitUpstreams { @@ -35,16 +141,6 @@ pub(crate) struct Preparation { pub(crate) notes: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RelayPluginDetection { - DetectedEnabled, - DetectedDisabled, - DetectedError, - Detected, - NotDetected, - Unknown, -} - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] enum AuthProfileEvidence { /// The read-only OpenClaw probe found no saved profiles for this provider. @@ -173,6 +269,12 @@ pub(crate) fn prepare( let source_path = resolve_config_path(argv)?; let (source_config, source_exists) = read_source_config(&source_path)?; + let disable_embedded_relay = source_config.as_ref().is_some_and(|config| { + config + .pointer("/plugins/entries") + .and_then(Value::as_object) + .is_some_and(|entries| entries.contains_key(RELAY_OPENCLAW_PLUGIN_ID)) + }) || probe_relay_plugin_installed(argv, &source_path); let auth_profiles = AuthProfileEvidenceSet { openai: probe_auth_profiles( argv, @@ -213,19 +315,10 @@ pub(crate) fn prepare( } } - routing.notes.push(plugin_detection_note(probe_relay_plugin( - argv, - &source_path, - ))); - - let overlay = overlay_document( - source_exists.then_some(source_path.as_path()), - routing.provider_overrides, - ); if dry_run { let mut notes = routing.notes; notes.push(format!( - "would generate a temporary OpenClaw JSON5 overlay for {}", + "would generate a temporary OpenClaw JSON5 overlay and CLI hook bridge for {}", source_path.display() )); return Ok(Preparation { @@ -239,12 +332,27 @@ pub(crate) fn prepare( }); } - let overlay_path = temporary_overlay_path(&source_path)?; + let bridge_dir = write_bridge_plugin()?; + let overlay = overlay_document( + source_exists.then_some(source_path.as_path()), + source_config.as_ref(), + routing.provider_overrides, + &bridge_dir, + disable_embedded_relay, + ); + let overlay_path = match temporary_overlay_path(&source_path) { + Ok(path) => path, + Err(error) => { + let _ = std::fs::remove_dir_all(&bridge_dir); + return Err(error); + } + }; let write_result = serde_json::to_vec_pretty(&overlay) .map_err(|error| CliError::Launch(format!("could not serialize OpenClaw overlay: {error}"))) .and_then(|contents| std::fs::write(&overlay_path, contents).map_err(CliError::from)); if let Err(error) = write_result { let _ = std::fs::remove_file(&overlay_path); + let _ = std::fs::remove_dir_all(&bridge_dir); return Err(error); } @@ -254,20 +362,16 @@ pub(crate) fn prepare( )]; Ok(Preparation { env, - temp_dirs: Vec::new(), + temp_dirs: vec![bridge_dir], temp_files: vec![overlay_path], - notes: routing.notes, + notes: routing + .notes + .into_iter() + .chain(["OpenClaw hooks forwarded to the CLI-owned Relay runtime".into()]) + .collect(), }) } -pub(crate) fn detect_relay_plugin() -> RelayPluginDetection { - let argv = vec!["openclaw".to_string()]; - let Ok(source_path) = resolve_config_path(&argv) else { - return RelayPluginDetection::Unknown; - }; - probe_relay_plugin(&argv, &source_path) -} - fn normalize_foreground_argv(argv: &mut Vec) -> Result<(), CliError> { let executable = openclaw_executable_index(argv).ok_or_else(|| { CliError::Launch( @@ -721,7 +825,13 @@ fn nonempty_dotenv_value(value: &str) -> bool { !value.is_empty() && value != "\"\"" && value != "''" } -fn overlay_document(source: Option<&Path>, providers: Map) -> Value { +fn overlay_document( + source: Option<&Path>, + source_config: Option<&Value>, + providers: Map, + bridge_dir: &Path, + disable_embedded_relay: bool, +) -> Value { let mut overlay = Map::new(); if let Some(source) = source { overlay.insert( @@ -732,9 +842,67 @@ fn overlay_document(source: Option<&Path>, providers: Map) -> Val if !providers.is_empty() { overlay.insert("models".into(), json!({"providers": providers})); } + let mut load_paths = source_config + .and_then(|config| config.pointer("/plugins/load/paths")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let bridge_path = bridge_dir.display().to_string(); + if !load_paths + .iter() + .any(|path| path.as_str() == Some(bridge_path.as_str())) + { + load_paths.push(Value::String(bridge_path)); + } + let mut entries = Map::from_iter([( + RELAY_OPENCLAW_BRIDGE_ID.to_string(), + json!({ + "enabled": true, + "config": {}, + "hooks": {"allowConversationAccess": true} + }), + )]); + if disable_embedded_relay { + entries.insert( + RELAY_OPENCLAW_PLUGIN_ID.to_string(), + json!({"enabled": false}), + ); + } + overlay.insert( + "plugins".into(), + json!({ + "enabled": true, + "load": {"paths": load_paths}, + "entries": entries + }), + ); Value::Object(overlay) } +fn probe_relay_plugin_installed(argv: &[String], source_path: &Path) -> bool { + let args = ["plugins", "inspect", RELAY_OPENCLAW_PLUGIN_ID, "--json"].map(str::to_string); + run_read_only_probe(argv, source_path, &args).is_some_and(|output| output.status.success()) +} + +fn write_bridge_plugin() -> Result { + let root = std::env::temp_dir().join(format!( + "nemo-relay-openclaw-bridge-{}-{}", + std::process::id(), + unique_stamp()? + )); + let result = std::fs::create_dir_all(&root) + .and_then(|()| std::fs::write(root.join("package.json"), BRIDGE_PACKAGE_JSON)) + .and_then(|()| std::fs::write(root.join("openclaw.plugin.json"), BRIDGE_MANIFEST_JSON)) + .and_then(|()| std::fs::write(root.join("index.js"), BRIDGE_INDEX_JS)); + if let Err(error) = result { + let _ = std::fs::remove_dir_all(&root); + return Err(CliError::Launch(format!( + "could not create temporary OpenClaw CLI bridge: {error}" + ))); + } + Ok(root) +} + fn add_invocation_metadata(gateway: &mut GatewayConfig) { let mut metadata = match gateway.metadata.take() { Some(Value::Object(metadata)) => metadata, @@ -788,13 +956,6 @@ fn resolve_config_path(argv: &[String]) -> Result { .join("openclaw.json")); } let primary = home.join(".openclaw").join("openclaw.json"); - if primary.exists() { - return Ok(primary); - } - let legacy = home.join(".clawdbot").join("clawdbot.json"); - if legacy.exists() { - return Ok(legacy); - } Ok(primary) } @@ -919,53 +1080,6 @@ fn resolve_openclaw_path(value: OsString) -> Result { } } -fn probe_relay_plugin(argv: &[String], source_path: &Path) -> RelayPluginDetection { - let args = ["plugins", "inspect", RELAY_OPENCLAW_PLUGIN_ID, "--json"].map(str::to_string); - let Some(output) = run_read_only_probe(argv, source_path, &args) else { - return RelayPluginDetection::Unknown; - }; - if output.status.success() { - let Ok(value) = serde_json::from_slice::(&output.stdout) else { - return RelayPluginDetection::Unknown; - }; - match plugin_status(&value) { - Some("error") => return RelayPluginDetection::DetectedError, - Some("disabled") => return RelayPluginDetection::DetectedDisabled, - _ => {} - } - return match plugin_enabled(&value) { - Some(true) => RelayPluginDetection::DetectedEnabled, - Some(false) => RelayPluginDetection::DetectedDisabled, - None => RelayPluginDetection::Detected, - }; - } - let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase(); - if stderr.contains("not found") - || stderr.contains("unknown plugin") - || stderr.contains("no plugin") - { - RelayPluginDetection::NotDetected - } else { - RelayPluginDetection::Unknown - } -} - -fn plugin_status(value: &Value) -> Option<&str> { - value - .get("status") - .and_then(Value::as_str) - .or_else(|| value.pointer("/plugin/status").and_then(Value::as_str)) - .or_else(|| value.pointer("/record/status").and_then(Value::as_str)) -} - -fn plugin_enabled(value: &Value) -> Option { - value - .get("enabled") - .and_then(Value::as_bool) - .or_else(|| value.pointer("/plugin/enabled").and_then(Value::as_bool)) - .or_else(|| value.pointer("/record/enabled").and_then(Value::as_bool)) -} - fn probe_auth_profiles( argv: &[String], source_path: &Path, @@ -1109,44 +1223,11 @@ fn probe_state_dir(argv: &[String]) -> Result { return Ok(home.join(format!(".openclaw-{profile}"))); } let primary = home.join(".openclaw"); - if primary.exists() { - return Ok(primary); - } - let legacy = home.join(".clawdbot"); - if legacy.exists() { - return Ok(legacy); - } Ok(primary) } -fn plugin_detection_note(detection: RelayPluginDetection) -> String { - match detection { - RelayPluginDetection::DetectedEnabled => format!( - "{RELAY_OPENCLAW_PACKAGE} detected; hook and tool telemetry remain owned by the embedded OpenClaw plugin" - ), - RelayPluginDetection::DetectedDisabled => format!( - "{RELAY_OPENCLAW_PACKAGE} is installed but disabled; Relay did not change OpenClaw plugin state" - ), - RelayPluginDetection::DetectedError => format!( - "{RELAY_OPENCLAW_PACKAGE} is installed but failed to load; Relay did not change OpenClaw plugin state" - ), - RelayPluginDetection::Detected => format!( - "{RELAY_OPENCLAW_PACKAGE} is installed; Relay did not change OpenClaw plugin state" - ), - RelayPluginDetection::NotDetected => format!( - "{RELAY_OPENCLAW_PACKAGE} not detected; Relay will not install it automatically, so this run has provider routing but no embedded hook/tool forwarding" - ), - RelayPluginDetection::Unknown => format!( - "could not determine whether {RELAY_OPENCLAW_PACKAGE} is installed; Relay did not modify OpenClaw plugin state" - ), - } -} - fn temporary_overlay_path(source_path: &Path) -> Result { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| CliError::Launch(error.to_string()))? - .as_nanos(); + let stamp = unique_stamp()?; let parent = source_path.parent().ok_or_else(|| { CliError::Launch(format!( "OpenClaw config path {} has no parent directory", @@ -1160,6 +1241,13 @@ fn temporary_overlay_path(source_path: &Path) -> Result { ))) } +fn unique_stamp() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| CliError::Launch(error.to_string())) + .map(|duration| duration.as_nanos()) +} + fn nonempty_env_os(name: &str) -> Option { std::env::var_os(name).filter(|value| { let value = value.to_string_lossy(); diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 20d8c3c12..a145a73bb 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -275,6 +275,7 @@ fn router_with_state(state: AppState) -> Router { .route("/hooks/codex", post(codex_hook)) .route("/hooks/claude-code", post(claude_code_hook)) .route("/hooks/hermes", post(hermes_hook)) + .route("/hooks/openclaw", post(openclaw_hook)) .route("/responses", post(gateway::passthrough)) .route("/chat/completions", post(gateway::passthrough)) .route("/models", get(gateway::models)) @@ -411,6 +412,23 @@ async fn hermes_hook( Ok(Json(outcome.response)) } +// Handles events from the temporary OpenClaw bridge injected by the CLI wrapper. The bridge does +// not host Relay or mutate tool results; provider requests still enter the managed LLM gateway. +async fn openclaw_hook( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, CliError> { + state.touch(); + let Json(payload) = payload.map_err(hook_payload_rejection)?; + let outcome = crate::adapters::openclaw::adapt(payload, &headers); + state + .sessions + .apply_events(&headers, outcome.events) + .await?; + Ok(Json(outcome.response)) +} + fn hook_payload_rejection(rejection: JsonRejection) -> CliError { if rejection.status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE { CliError::PayloadTooLarge(rejection.to_string()) diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 934b65913..05a58ed54 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -270,7 +270,7 @@ fn ask_agents( CodingAgent::ClaudeCode, CodingAgent::Codex, CodingAgent::Hermes, - CodingAgent::Openclaw, + CodingAgent::OpenClaw, ]; let labels: Vec = all_supported .iter() diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs index 61d95c61d..e2dcfed4a 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/setup/model.rs @@ -91,7 +91,7 @@ pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> (CodingAgent::ClaudeCode, "claude"), (CodingAgent::Codex, "codex"), (CodingAgent::Hermes, "hermes"), - (CodingAgent::Openclaw, "openclaw"), + (CodingAgent::OpenClaw, "openclaw"), ]; candidates .into_iter() @@ -410,7 +410,7 @@ pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { "claude" => Some(CodingAgent::ClaudeCode), "codex" => Some(CodingAgent::Codex), "hermes" => Some(CodingAgent::Hermes), - "openclaw" => Some(CodingAgent::Openclaw), + "openclaw" => Some(CodingAgent::OpenClaw), _ => None, }; if let Some(agent) = agent { @@ -425,7 +425,7 @@ pub(super) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'stat CodingAgent::ClaudeCode => ("claude", "claude"), CodingAgent::Codex => ("codex", "codex"), CodingAgent::Hermes => ("hermes", "hermes"), - CodingAgent::Openclaw => ("openclaw", "openclaw"), + CodingAgent::OpenClaw => ("openclaw", "openclaw"), } } diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 91dfa0b71..13c4469da 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -1172,7 +1172,7 @@ fn cli_help_lists_easy_path_agent_shortcuts() { let output = Command::new(gateway_bin()).arg("--help").output().unwrap(); let stdout = String::from_utf8_lossy(&output.stdout); - for agent in ["claude", "codex", "hermes"] { + for agent in ["claude", "codex", "hermes", "openclaw"] { assert!( stdout.contains(&format!(" {agent}")), "expected `--help` to list `{agent}` subcommand, got:\n{stdout}" diff --git a/crates/cli/tests/coverage/adapters_tests.rs b/crates/cli/tests/coverage/adapters_tests.rs index 685f3c23f..b292ef717 100644 --- a/crates/cli/tests/coverage/adapters_tests.rs +++ b/crates/cli/tests/coverage/adapters_tests.rs @@ -5,7 +5,63 @@ use axum::http::HeaderMap; use serde_json::json; use super::*; -use crate::adapters::{claude_code, codex, hermes}; +use crate::adapters::{claude_code, codex, hermes, openclaw}; + +#[test] +fn maps_openclaw_bridge_lifecycle_llm_and_tool_events() { + let headers = HeaderMap::new(); + + let started = openclaw::adapt( + json!({ + "session_id": "openclaw-session", + "hook_event_name": "session_start" + }), + &headers, + ); + assert!(matches!( + &started.events[0], + NormalizedEvent::AgentStarted(event) + if event.agent_kind == AgentKind::OpenClaw + && event.session_id == "openclaw-session" + )); + + let hint = openclaw::adapt( + json!({ + "session_id": "openclaw-session", + "hook_event_name": "preLlmCall", + "request_id": "run-1", + "model": "claude-test" + }), + &headers, + ); + assert!(matches!( + &hint.events[0], + NormalizedEvent::LlmHint(event) + if event.agent_kind == AgentKind::OpenClaw + && event.request_id.as_deref() == Some("run-1") + && event.model.as_deref() == Some("claude-test") + )); + + let tool = openclaw::adapt( + json!({ + "session_id": "openclaw-session", + "hook_event_name": "tool_end", + "tool_call_id": "tool-1", + "tool_name": "exec", + "tool_input": {"command": "pwd"}, + "tool_output": {"content": [{"type": "text", "text": "/workspace"}]} + }), + &headers, + ); + assert!(matches!( + &tool.events[0], + NormalizedEvent::ToolEnded(event) + if event.agent_kind == AgentKind::OpenClaw + && event.tool_call_id == "tool-1" + && event.tool_name == "exec" + && event.arguments == json!({"command": "pwd"}) + )); +} #[test] fn maps_claude_canonical_tool_payload() { diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index b07553498..e207fb111 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -295,7 +295,8 @@ fn agent_and_gateway_mode_arguments_are_stable() { assert_eq!(CodingAgent::ClaudeCode.hook_path(), "/hooks/claude-code"); assert_eq!(CodingAgent::Codex.hook_path(), "/hooks/codex"); assert_eq!(CodingAgent::Hermes.hook_path(), "/hooks/hermes"); - assert_eq!(CodingAgent::Openclaw.as_arg(), "openclaw"); + assert_eq!(CodingAgent::OpenClaw.hook_path(), "/hooks/openclaw"); + assert_eq!(CodingAgent::OpenClaw.as_arg(), "openclaw"); assert_eq!(GatewayMode::HookOnly.as_arg(), "hook-only"); assert_eq!(GatewayMode::Passthrough.as_arg(), "passthrough"); assert_eq!(GatewayMode::Required.as_arg(), "required"); @@ -312,7 +313,7 @@ fn agent_inference_uses_executable_basename() { assert_eq!(CodingAgent::infer("hermes"), Some(CodingAgent::Hermes)); assert_eq!( CodingAgent::infer("/opt/bin/openclaw"), - Some(CodingAgent::Openclaw) + Some(CodingAgent::OpenClaw) ); assert_eq!(CodingAgent::infer("wrapper"), None); } diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index 161ed8e30..3f51330d4 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -227,7 +227,7 @@ 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"); - assert_eq!(default_command_for(CodingAgent::Openclaw), "openclaw"); + assert_eq!(default_command_for(CodingAgent::OpenClaw), "openclaw"); let agents = AgentConfigs { codex: AgentCommandConfig { @@ -249,7 +249,7 @@ fn resolves_configured_openclaw_command_and_inference() { ..AgentConfigs::default() }; let command = RunCommand { - agent: Some(CodingAgent::Openclaw), + agent: Some(CodingAgent::OpenClaw), config: None, openai_base_url: None, anthropic_base_url: None, @@ -262,7 +262,7 @@ fn resolves_configured_openclaw_command_and_inference() { let (agent, argv) = resolve_agent_and_argv(&command, &agents).unwrap(); - assert_eq!(agent, CodingAgent::Openclaw); + assert_eq!(agent, CodingAgent::OpenClaw); assert_eq!(argv, ["openclaw", "tui"]); } diff --git a/crates/cli/tests/coverage/openclaw_tests.rs b/crates/cli/tests/coverage/openclaw_tests.rs index aef25bef6..74f0dc6be 100644 --- a/crates/cli/tests/coverage/openclaw_tests.rs +++ b/crates/cli/tests/coverage/openclaw_tests.rs @@ -423,6 +423,7 @@ fn temporary_overlay_preserves_source_config_metadata_and_explicit_relay_upstrea let original = r#"{ // This comment and formatting must remain byte-for-byte unchanged. agents: { defaults: { model: { primary: "openai/gpt-test" } } }, + plugins: { load: { paths: ["/existing/openclaw/plugin"] } }, models: { providers: { openai: { baseUrl: "https://source-upstream.example/v1", api: "openai-responses", @@ -437,6 +438,9 @@ fn temporary_overlay_preserves_source_config_metadata_and_explicit_relay_upstrea &executable, r#"#!/bin/sh case "$*" in + "plugins inspect nemo-relay --json") + printf '%s' '{"plugin":{"id":"nemo-relay","enabled":true}}' + ;; *"models auth list --provider openai --json"*|*"models auth list --provider anthropic --json"*) printf '%s' '{"profiles":[]}' ;; @@ -496,15 +500,69 @@ esac overlay["models"]["providers"]["openai"]["baseUrl"], "http://127.0.0.1:43127/v1" ); + assert_eq!( + overlay["plugins"]["entries"][RELAY_OPENCLAW_PLUGIN_ID]["enabled"], + false + ); + assert_eq!( + overlay["plugins"]["entries"][RELAY_OPENCLAW_BRIDGE_ID]["enabled"], + true + ); + assert_eq!( + overlay["plugins"]["entries"][RELAY_OPENCLAW_BRIDGE_ID]["hooks"]["allowConversationAccess"], + true + ); + let bridge_dir = prepared.temp_dirs.first().unwrap(); + assert_eq!( + overlay["plugins"]["load"]["paths"], + json!([ + "/existing/openclaw/plugin", + bridge_dir.display().to_string() + ]) + ); + assert_eq!( + std::fs::read_to_string(bridge_dir.join("package.json")).unwrap(), + BRIDGE_PACKAGE_JSON + ); + assert_eq!( + std::fs::read_to_string(bridge_dir.join("openclaw.plugin.json")).unwrap(), + BRIDGE_MANIFEST_JSON + ); + let bridge = std::fs::read_to_string(bridge_dir.join("index.js")).unwrap(); + assert!(bridge.contains("/hooks/openclaw")); + assert!(bridge.contains("before_tool_call")); + assert!(bridge.contains("after_tool_call")); + assert!(!bridge.contains("nemo-relay-node")); + assert!(!bridge.contains("registerAgentToolResultMiddleware")); assert_eq!(overlay_path.parent(), source.parent()); std::fs::remove_file(&prepared.temp_files[0]).unwrap(); + std::fs::remove_dir_all(bridge_dir).unwrap(); assert!(!overlay_path.exists()); + assert!(!bridge_dir.exists()); assert!(source.exists()); } +#[test] +fn config_resolution_does_not_fall_back_to_legacy_clawdbot_state() { + let temp = tempfile::tempdir().unwrap(); + let legacy = temp.path().join(".clawdbot/clawdbot.json"); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, "{}").unwrap(); + let _env = EnvScope::provider_test(&[("OPENCLAW_HOME", Some(temp.path().as_os_str()))]); + + assert_eq!( + resolve_config_path(&["openclaw".into()]).unwrap(), + temp.path().join(".openclaw/openclaw.json") + ); + assert_eq!( + probe_state_dir(&["openclaw".into()]).unwrap(), + temp.path().join(".openclaw") + ); +} + #[cfg(unix)] #[test] -fn read_only_probes_detect_plugin_state_and_deterministic_auth_profiles() { +fn read_only_probes_detect_deterministic_auth_profiles() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().unwrap(); @@ -515,9 +573,6 @@ fn read_only_probes_detect_plugin_state_and_deterministic_auth_profiles() { &executable, r#"#!/bin/sh case "$*" in - "plugins inspect nemo-relay --json") - printf '%s' '{"enabled":false}' - ;; *"models auth --agent work list --provider openai --json"*) printf '%s' '{"profiles":[{"id":"openai:work","type":"api_key"}]}' ;; @@ -539,10 +594,6 @@ esac std::fs::set_permissions(&executable, permissions).unwrap(); let argv = vec![executable.display().to_string()]; - assert_eq!( - probe_relay_plugin(&argv, &source), - RelayPluginDetection::DetectedDisabled - ); assert_eq!( probe_auth_profiles(&argv, &source, None, ProviderFamily::OpenAi), AuthProfileEvidence::ApiKeyOnly @@ -563,16 +614,6 @@ esac probe_auth_profiles(&selected, &source, None, ProviderFamily::OpenAi), AuthProfileEvidence::ApiKeyOnly ); - - std::fs::write( - &executable, - "#!/bin/sh\nprintf '%s' '{\"plugin\":{\"enabled\":true,\"status\":\"error\"}}'\n", - ) - .unwrap(); - assert_eq!( - probe_relay_plugin(&argv, &source), - RelayPluginDetection::DetectedError - ); } #[cfg(unix)] diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 25d7ac598..08a3f540b 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -302,6 +302,32 @@ async fn codex_hook_keeps_codex_response_shape() { assert_eq!(body, json!({})); } +#[tokio::test] +async fn openclaw_bridge_hook_is_accepted() { + let app = router(test_config()); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/hooks/openclaw") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "session_id": "openclaw-1", + "hook_event_name": "session_start" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body, json!({})); +} + #[tokio::test] async fn hook_payload_above_axum_default_succeeds_with_relay_default_limit() { let app = router(test_config()); diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index fc30a22ee..f9a5e2e09 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -105,7 +105,7 @@ fn detect_installed_agents_finds_binaries_on_path() { let detected = detect_installed_agents_in(Some(temp.path().as_os_str())); assert!(detected.contains(&CodingAgent::ClaudeCode)); assert!(detected.contains(&CodingAgent::Hermes)); - assert!(detected.contains(&CodingAgent::Openclaw)); + assert!(detected.contains(&CodingAgent::OpenClaw)); assert!(!detected.contains(&CodingAgent::Codex)); } @@ -155,7 +155,7 @@ fn build_config_emits_agents_block_with_user_facing_keys() { agents: vec![ CodingAgent::ClaudeCode, CodingAgent::Codex, - CodingAgent::Openclaw, + CodingAgent::OpenClaw, ], hermes_hooks_path: None, };