Skip to main content

nemo_relay/
plugin.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Generic plugin infrastructure for NeMo Relay runtimes.
5//!
6//! This module owns:
7//! - config diagnostics and policy enums used by plugin systems
8//! - a global plugin registry
9//! - plugin registration contexts for middleware/subscriber installation
10//! - rollback bookkeeping for registrations created during plugin setup
11
12use std::cell::Cell;
13use std::collections::{HashMap, HashSet};
14use std::fmt;
15use std::future::Future;
16use std::panic::{AssertUnwindSafe, catch_unwind};
17use std::pin::Pin;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::{Arc, LazyLock, Mutex, OnceLock, RwLock};
20
21use serde::{Deserialize, Serialize};
22use serde_json::{Map, Value as Json};
23use thiserror::Error;
24
25use crate::api::registry::{
26    deregister_llm_conditional_execution_guardrail, deregister_llm_execution_intercept,
27    deregister_llm_request_intercept, deregister_llm_sanitize_request_guardrail,
28    deregister_llm_sanitize_response_guardrail, deregister_llm_stream_execution_intercept,
29    deregister_mark_sanitize_guardrail, deregister_scope_sanitize_end_guardrail,
30    deregister_scope_sanitize_start_guardrail, deregister_tool_conditional_execution_guardrail,
31    deregister_tool_execution_intercept, deregister_tool_request_intercept,
32    deregister_tool_sanitize_request_guardrail, deregister_tool_sanitize_response_guardrail,
33    register_llm_conditional_execution_guardrail, register_llm_execution_intercept,
34    register_llm_request_intercept, register_llm_sanitize_request_guardrail,
35    register_llm_sanitize_response_guardrail, register_llm_stream_execution_intercept,
36    register_mark_sanitize_guardrail, register_scope_sanitize_end_guardrail,
37    register_scope_sanitize_start_guardrail, register_tool_conditional_execution_guardrail,
38    register_tool_execution_intercept, register_tool_request_intercept,
39    register_tool_sanitize_request_guardrail, register_tool_sanitize_response_guardrail,
40};
41use crate::api::runtime::{
42    EventSanitizeFn, EventSubscriberFn, LlmConditionalFn, LlmExecutionFn, LlmRequestInterceptFn,
43    LlmSanitizeRequestFn, LlmSanitizeResponseFn, LlmStreamExecutionFn, ToolConditionalFn,
44    ToolExecutionFn, ToolInterceptFn, ToolSanitizeFn,
45};
46use crate::api::subscriber::{deregister_subscriber, register_subscriber};
47pub use nemo_relay_types::plugin::{ConfigDiagnostic, DiagnosticLevel};
48
49pub mod dynamic;
50pub use dynamic::*;
51
52type PluginMap = HashMap<String, RegisteredPlugin>;
53
54struct RegisteredPlugin {
55    registration_id: u64,
56    owner: PluginRegistrationOwner,
57    plugin: Arc<dyn Plugin>,
58}
59
60#[derive(Clone, Copy, PartialEq, Eq)]
61enum PluginRegistrationOwner {
62    Builtin,
63    External,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub(crate) enum PluginDeregistrationOutcome {
68    Removed,
69    Missing,
70    Replaced,
71}
72
73static PLUGIN_HANDLERS: LazyLock<RwLock<PluginMap>> = LazyLock::new(|| RwLock::new(HashMap::new()));
74static ACTIVE_PLUGIN_CONFIGURATION: LazyLock<Mutex<Option<ActivePluginConfiguration>>> =
75    LazyLock::new(|| Mutex::new(None));
76static LAST_FAILED_RUNTIME_DIAGNOSTICS_REPORT: LazyLock<Mutex<Option<ConfigReport>>> =
77    LazyLock::new(|| Mutex::new(None));
78static PLUGIN_MUTATION_OWNER: LazyLock<Mutex<PluginMutationOwner>> =
79    LazyLock::new(|| Mutex::new(PluginMutationOwner::Idle));
80static NEXT_PLUGIN_REGISTRATION_ID: AtomicU64 = AtomicU64::new(1);
81static NEXT_PLUGIN_HOST_OWNER_ID: AtomicU64 = AtomicU64::new(1);
82static PLUGIN_MUTATION_EXECUTOR: OnceLock<PluginMutationSender> = OnceLock::new();
83
84type PluginMutationJob = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
85type PluginMutationSender = tokio::sync::mpsc::UnboundedSender<PluginMutationJob>;
86
87thread_local! {
88    static IN_PLUGIN_MUTATION_EXECUTOR: Cell<bool> = const { Cell::new(false) };
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92enum PluginMutationOwner {
93    Idle,
94    Legacy,
95    Host(u64),
96}
97
98/// Error type for generic plugin operations.
99#[derive(Debug, Error)]
100pub enum PluginError {
101    /// Configuration validation failed.
102    #[error("invalid config: {0}")]
103    InvalidConfig(String),
104
105    /// The requested mutation conflicts with current plugin state.
106    #[error("conflict: {0}")]
107    Conflict(String),
108
109    /// The requested plugin resource was not found.
110    #[error("not found: {0}")]
111    NotFound(String),
112
113    /// A serialization or deserialization operation failed.
114    #[error("serialization error: {0}")]
115    Serialization(#[from] serde_json::Error),
116
117    /// An internal plugin-system error occurred.
118    #[error("internal error: {0}")]
119    Internal(String),
120
121    /// A runtime middleware/subscriber registration failed.
122    #[error("registration failed: {0}")]
123    RegistrationFailed(String),
124}
125
126/// Specialized [`Result`](std::result::Result) type for plugin operations.
127pub type Result<T> = std::result::Result<T, PluginError>;
128
129/// Identifies teardown errors caused by recoverable ATIF delivery failures.
130pub(crate) const ATIF_RUNTIME_DELIVERY_FAILURE_MARKER: &str = "ATIF runtime delivery failures";
131/// Identifies teardown errors caused by recoverable OpenTelemetry delivery failures.
132pub(crate) const OTEL_RUNTIME_DELIVERY_FAILURE_MARKER: &str =
133    "OpenTelemetry runtime delivery failures";
134
135/// Canonical plugin configuration document.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138pub struct PluginConfig {
139    /// Plugin config schema version.
140    #[serde(default = "default_plugin_config_version")]
141    pub version: u32,
142    /// Ordered list of top-level plugin components to validate and activate.
143    #[serde(default)]
144    pub components: Vec<PluginComponentSpec>,
145    /// Plugin-level policy for unsupported plugin kinds, fields, and values.
146    ///
147    /// Non-default field values override the corresponding component policy.
148    #[serde(default)]
149    pub policy: ConfigPolicy,
150}
151
152impl Default for PluginConfig {
153    fn default() -> Self {
154        Self {
155            version: default_plugin_config_version(),
156            components: vec![],
157            policy: ConfigPolicy::default(),
158        }
159    }
160}
161
162/// One configured plugin component.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
165pub struct PluginComponentSpec {
166    /// Registered plugin kind string.
167    pub kind: String,
168    /// Whether the component should be activated.
169    ///
170    /// Disabled components are still validated but skipped during runtime
171    /// registration.
172    #[serde(default = "default_enabled")]
173    pub enabled: bool,
174    /// Component-local JSON config object passed to the plugin.
175    #[serde(default)]
176    pub config: Map<String, Json>,
177}
178
179impl PluginComponentSpec {
180    /// Creates a new enabled component spec with empty config.
181    pub fn new(kind: impl Into<String>) -> Self {
182        Self {
183            kind: kind.into(),
184            enabled: true,
185            config: Map::new(),
186        }
187    }
188}
189
190/// Structured validation report.
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
193pub struct ConfigReport {
194    /// Validation and compatibility diagnostics in evaluation order.
195    #[serde(default)]
196    pub diagnostics: Vec<ConfigDiagnostic>,
197    /// Runtime delivery diagnostics recorded after activation.
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub runtime_diagnostics: Vec<RuntimeDiagnostic>,
200}
201
202/// Bounded aggregate for a runtime failure observed by an active plugin.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
205pub struct RuntimeDiagnostic {
206    /// Stable failure classification.
207    pub code: String,
208    /// Plugin component that reported the failure.
209    pub component: String,
210    /// Optional configuration field associated with the failure.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub field: Option<String>,
213    /// Latest human-readable failure detail.
214    pub message: String,
215    /// Latest affected trajectory session identifier.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub session_id: Option<String>,
218    /// Number of failures aggregated into this entry.
219    pub count: u64,
220}
221
222impl ConfigReport {
223    /// Returns `true` when the report contains at least one error diagnostic.
224    pub fn has_errors(&self) -> bool {
225        self.diagnostics
226            .iter()
227            .any(|diag| diag.level == DiagnosticLevel::Error)
228    }
229}
230
231/// Policy for how unsupported plugin/runtime config is handled.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
234pub struct ConfigPolicy {
235    /// Policy applied when a component kind is unknown to the plugin registry.
236    #[serde(default = "default_warn")]
237    pub unknown_component: UnsupportedBehavior,
238    /// Policy applied when a known component contains an unknown field.
239    #[serde(default = "default_warn")]
240    pub unknown_field: UnsupportedBehavior,
241    /// Policy applied when a known field contains an unsupported value.
242    #[serde(default = "default_error")]
243    pub unsupported_value: UnsupportedBehavior,
244}
245
246impl Default for ConfigPolicy {
247    fn default() -> Self {
248        Self {
249            unknown_component: default_warn(),
250            unknown_field: default_warn(),
251            unsupported_value: default_error(),
252        }
253    }
254}
255
256/// Applies non-default global policy fields to a component policy.
257///
258/// Component-specific policy remains in effect when the corresponding global
259/// setting is left at its default value.
260pub fn apply_global_config_policy(
261    component_policy: ConfigPolicy,
262    global_policy: &ConfigPolicy,
263) -> ConfigPolicy {
264    let default_policy = ConfigPolicy::default();
265    ConfigPolicy {
266        unknown_component: if global_policy.unknown_component != default_policy.unknown_component {
267            global_policy.unknown_component
268        } else {
269            component_policy.unknown_component
270        },
271        unknown_field: if global_policy.unknown_field != default_policy.unknown_field {
272            global_policy.unknown_field
273        } else {
274            component_policy.unknown_field
275        },
276        unsupported_value: if global_policy.unsupported_value != default_policy.unsupported_value {
277            global_policy.unsupported_value
278        } else {
279            component_policy.unsupported_value
280        },
281    }
282}
283
284crate::editor_config! {
285    impl ConfigPolicy {
286        unknown_component => {
287            label: "unknown_component",
288            kind: Enum,
289            values: ["warn", "ignore", "error"],
290        },
291        unknown_field => {
292            label: "unknown_field",
293            kind: Enum,
294            values: ["warn", "ignore", "error"],
295        },
296        unsupported_value => {
297            label: "unsupported_value",
298            kind: Enum,
299            values: ["warn", "ignore", "error"],
300        },
301    }
302}
303
304/// Per-policy behavior for unsupported configuration.
305#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
306#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
307#[serde(rename_all = "lowercase")]
308pub enum UnsupportedBehavior {
309    /// Suppress the diagnostic entirely.
310    Ignore,
311    /// Emit a warning diagnostic.
312    #[default]
313    Warn,
314    /// Emit an error diagnostic.
315    Error,
316}
317
318fn default_warn() -> UnsupportedBehavior {
319    UnsupportedBehavior::Warn
320}
321
322fn default_error() -> UnsupportedBehavior {
323    UnsupportedBehavior::Error
324}
325
326fn default_plugin_config_version() -> u32 {
327    1
328}
329
330fn default_enabled() -> bool {
331    true
332}
333
334/// Bookkeeping for one middleware/subscriber registration.
335pub struct PluginRegistration {
336    /// Registration kind used for bookkeeping.
337    pub kind: String,
338    /// Runtime-qualified registration name.
339    pub name: String,
340    deregister: Box<dyn FnMut() -> PluginRegistrationCleanupOutcome + Send>,
341}
342
343pub(crate) enum PluginRegistrationCleanupOutcome {
344    Removed,
345    RemovedWithError(PluginError),
346    NotRemoved(PluginError),
347}
348
349impl fmt::Debug for PluginRegistration {
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        f.debug_struct("PluginRegistration")
352            .field("kind", &self.kind)
353            .field("name", &self.name)
354            .finish_non_exhaustive()
355    }
356}
357
358impl PluginRegistration {
359    /// Creates a new registration bookkeeping entry.
360    pub fn new(
361        kind: impl Into<String>,
362        name: impl Into<String>,
363        mut deregister: Box<dyn FnMut() -> Result<()> + Send>,
364    ) -> Self {
365        Self {
366            kind: kind.into(),
367            name: name.into(),
368            deregister: Box::new(move || match deregister() {
369                Ok(()) => PluginRegistrationCleanupOutcome::Removed,
370                Err(error) => PluginRegistrationCleanupOutcome::NotRemoved(error),
371            }),
372        }
373    }
374
375    pub(crate) fn new_with_outcome(
376        kind: impl Into<String>,
377        name: impl Into<String>,
378        deregister: Box<dyn FnMut() -> PluginRegistrationCleanupOutcome + Send>,
379    ) -> Self {
380        Self {
381            kind: kind.into(),
382            name: name.into(),
383            deregister,
384        }
385    }
386}
387
388/// Context provided to plugin handlers during runtime registration.
389///
390/// Each `register_*` call both installs the middleware/subscriber into the
391/// NeMo Relay runtime and records the inverse deregistration closure so the host
392/// can roll back partial setup on failure.
393#[derive(Default)]
394pub struct PluginRegistrationContext {
395    registrations: Vec<PluginRegistration>,
396    namespace: Option<String>,
397}
398
399impl PluginRegistrationContext {
400    /// Creates an empty plugin registration context.
401    pub fn new() -> Self {
402        Self::default()
403    }
404
405    /// Creates a plugin registration context that namespaces all registration names.
406    pub fn with_namespace(namespace: impl Into<String>) -> Self {
407        Self {
408            registrations: vec![],
409            namespace: Some(namespace.into()),
410        }
411    }
412
413    /// Returns the runtime-qualified name for a plugin-local registration.
414    ///
415    /// Plugin handlers should pass stable component-local names such as
416    /// `"tool"` or `"subscriber"`. The host applies the namespace so users do
417    /// not have to provide component instance ids.
418    pub fn qualify_name(&self, name: &str) -> String {
419        match &self.namespace {
420            Some(namespace) => format!("{namespace}{name}"),
421            None => name.to_string(),
422        }
423    }
424
425    /// Registers an event subscriber and records its rollback closure.
426    pub fn register_subscriber(&mut self, name: &str, callback: EventSubscriberFn) -> Result<()> {
427        let qualified_name = self.qualify_name(name);
428        register_subscriber(&qualified_name, callback)
429            .map_err(|err| PluginError::RegistrationFailed(format!("subscriber: {err}")))?;
430
431        let name_owned = qualified_name;
432        self.registrations.push(PluginRegistration::new(
433            "plugin",
434            name_owned.clone(),
435            Box::new(move || {
436                deregister_subscriber(&name_owned)
437                    .map(|_| ())
438                    .map_err(|err| {
439                        PluginError::RegistrationFailed(format!(
440                            "subscriber deregistration failed: {err}"
441                        ))
442                    })
443            }),
444        ));
445        Ok(())
446    }
447
448    /// Registers a mark event sanitizer and records its rollback closure.
449    pub fn register_mark_sanitize_guardrail(
450        &mut self,
451        name: &str,
452        priority: i32,
453        callback: EventSanitizeFn,
454    ) -> Result<()> {
455        let qualified_name = self.qualify_name(name);
456        register_mark_sanitize_guardrail(&qualified_name, priority, callback)
457            .map_err(|err| PluginError::RegistrationFailed(format!("mark sanitizer: {err}")))?;
458        let name_owned = qualified_name;
459        self.registrations.push(PluginRegistration::new(
460            "plugin",
461            name_owned.clone(),
462            Box::new(move || {
463                deregister_mark_sanitize_guardrail(&name_owned)
464                    .map(|_| ())
465                    .map_err(|err| {
466                        PluginError::RegistrationFailed(format!(
467                            "mark sanitizer deregistration failed: {err}"
468                        ))
469                    })
470            }),
471        ));
472        Ok(())
473    }
474
475    /// Registers a scope-start event sanitizer and records its rollback closure.
476    pub fn register_scope_sanitize_start_guardrail(
477        &mut self,
478        name: &str,
479        priority: i32,
480        callback: EventSanitizeFn,
481    ) -> Result<()> {
482        let qualified_name = self.qualify_name(name);
483        register_scope_sanitize_start_guardrail(&qualified_name, priority, callback).map_err(
484            |err| PluginError::RegistrationFailed(format!("scope-start sanitizer: {err}")),
485        )?;
486        let name_owned = qualified_name;
487        self.registrations.push(PluginRegistration::new(
488            "plugin",
489            name_owned.clone(),
490            Box::new(move || {
491                deregister_scope_sanitize_start_guardrail(&name_owned)
492                    .map(|_| ())
493                    .map_err(|err| {
494                        PluginError::RegistrationFailed(format!(
495                            "scope-start sanitizer deregistration failed: {err}"
496                        ))
497                    })
498            }),
499        ));
500        Ok(())
501    }
502
503    /// Registers a scope-end event sanitizer and records its rollback closure.
504    pub fn register_scope_sanitize_end_guardrail(
505        &mut self,
506        name: &str,
507        priority: i32,
508        callback: EventSanitizeFn,
509    ) -> Result<()> {
510        let qualified_name = self.qualify_name(name);
511        register_scope_sanitize_end_guardrail(&qualified_name, priority, callback).map_err(
512            |err| PluginError::RegistrationFailed(format!("scope-end sanitizer: {err}")),
513        )?;
514        let name_owned = qualified_name;
515        self.registrations.push(PluginRegistration::new(
516            "plugin",
517            name_owned.clone(),
518            Box::new(move || {
519                deregister_scope_sanitize_end_guardrail(&name_owned)
520                    .map(|_| ())
521                    .map_err(|err| {
522                        PluginError::RegistrationFailed(format!(
523                            "scope-end sanitizer deregistration failed: {err}"
524                        ))
525                    })
526            }),
527        ));
528        Ok(())
529    }
530
531    /// Registers an LLM request intercept and records its rollback closure.
532    pub fn register_llm_request_intercept(
533        &mut self,
534        name: &str,
535        priority: i32,
536        break_chain: bool,
537        callback: LlmRequestInterceptFn,
538    ) -> Result<()> {
539        let qualified_name = self.qualify_name(name);
540        register_llm_request_intercept(&qualified_name, priority, break_chain, callback).map_err(
541            |err| PluginError::RegistrationFailed(format!("llm request intercept: {err}")),
542        )?;
543
544        let name_owned = qualified_name;
545        self.registrations.push(PluginRegistration::new(
546            "plugin",
547            name_owned.clone(),
548            Box::new(move || {
549                deregister_llm_request_intercept(&name_owned)
550                    .map(|_| ())
551                    .map_err(|err| {
552                        PluginError::RegistrationFailed(format!(
553                            "llm request intercept deregistration failed: {err}"
554                        ))
555                    })
556            }),
557        ));
558        Ok(())
559    }
560
561    /// Registers a tool sanitize-request guardrail and records its rollback closure.
562    pub fn register_tool_sanitize_request_guardrail(
563        &mut self,
564        name: &str,
565        priority: i32,
566        callback: ToolSanitizeFn,
567    ) -> Result<()> {
568        let qualified_name = self.qualify_name(name);
569        register_tool_sanitize_request_guardrail(&qualified_name, priority, callback).map_err(
570            |err| {
571                PluginError::RegistrationFailed(format!("tool sanitize request guardrail: {err}"))
572            },
573        )?;
574
575        let name_owned = qualified_name;
576        self.registrations.push(PluginRegistration::new(
577            "plugin",
578            name_owned.clone(),
579            Box::new(move || {
580                deregister_tool_sanitize_request_guardrail(&name_owned)
581                    .map(|_| ())
582                    .map_err(|err| {
583                        PluginError::RegistrationFailed(format!(
584                            "tool sanitize request guardrail deregistration failed: {err}"
585                        ))
586                    })
587            }),
588        ));
589        Ok(())
590    }
591
592    /// Registers a tool sanitize-response guardrail and records its rollback closure.
593    pub fn register_tool_sanitize_response_guardrail(
594        &mut self,
595        name: &str,
596        priority: i32,
597        callback: ToolSanitizeFn,
598    ) -> Result<()> {
599        let qualified_name = self.qualify_name(name);
600        register_tool_sanitize_response_guardrail(&qualified_name, priority, callback).map_err(
601            |err| {
602                PluginError::RegistrationFailed(format!("tool sanitize response guardrail: {err}"))
603            },
604        )?;
605
606        let name_owned = qualified_name;
607        self.registrations.push(PluginRegistration::new(
608            "plugin",
609            name_owned.clone(),
610            Box::new(move || {
611                deregister_tool_sanitize_response_guardrail(&name_owned)
612                    .map(|_| ())
613                    .map_err(|err| {
614                        PluginError::RegistrationFailed(format!(
615                            "tool sanitize response guardrail deregistration failed: {err}"
616                        ))
617                    })
618            }),
619        ));
620        Ok(())
621    }
622
623    /// Registers a tool conditional-execution guardrail and records its rollback closure.
624    pub fn register_tool_conditional_execution_guardrail(
625        &mut self,
626        name: &str,
627        priority: i32,
628        callback: ToolConditionalFn,
629    ) -> Result<()> {
630        let qualified_name = self.qualify_name(name);
631        register_tool_conditional_execution_guardrail(&qualified_name, priority, callback)
632            .map_err(|err| {
633                PluginError::RegistrationFailed(format!(
634                    "tool conditional execution guardrail: {err}"
635                ))
636            })?;
637
638        let name_owned = qualified_name;
639        self.registrations.push(PluginRegistration::new(
640            "plugin",
641            name_owned.clone(),
642            Box::new(move || {
643                deregister_tool_conditional_execution_guardrail(&name_owned)
644                    .map(|_| ())
645                    .map_err(|err| {
646                        PluginError::RegistrationFailed(format!(
647                            "tool conditional execution guardrail deregistration failed: {err}"
648                        ))
649                    })
650            }),
651        ));
652        Ok(())
653    }
654
655    /// Registers an LLM sanitize-request guardrail and records its rollback closure.
656    pub fn register_llm_sanitize_request_guardrail(
657        &mut self,
658        name: &str,
659        priority: i32,
660        callback: LlmSanitizeRequestFn,
661    ) -> Result<()> {
662        let qualified_name = self.qualify_name(name);
663        register_llm_sanitize_request_guardrail(&qualified_name, priority, callback).map_err(
664            |err| PluginError::RegistrationFailed(format!("llm sanitize request guardrail: {err}")),
665        )?;
666
667        let name_owned = qualified_name;
668        self.registrations.push(PluginRegistration::new(
669            "plugin",
670            name_owned.clone(),
671            Box::new(move || {
672                deregister_llm_sanitize_request_guardrail(&name_owned)
673                    .map(|_| ())
674                    .map_err(|err| {
675                        PluginError::RegistrationFailed(format!(
676                            "llm sanitize request guardrail deregistration failed: {err}"
677                        ))
678                    })
679            }),
680        ));
681        Ok(())
682    }
683
684    /// Registers an LLM sanitize-response guardrail and records its rollback closure.
685    pub fn register_llm_sanitize_response_guardrail(
686        &mut self,
687        name: &str,
688        priority: i32,
689        callback: LlmSanitizeResponseFn,
690    ) -> Result<()> {
691        let qualified_name = self.qualify_name(name);
692        register_llm_sanitize_response_guardrail(&qualified_name, priority, callback).map_err(
693            |err| {
694                PluginError::RegistrationFailed(format!("llm sanitize response guardrail: {err}"))
695            },
696        )?;
697
698        let name_owned = qualified_name;
699        self.registrations.push(PluginRegistration::new(
700            "plugin",
701            name_owned.clone(),
702            Box::new(move || {
703                deregister_llm_sanitize_response_guardrail(&name_owned)
704                    .map(|_| ())
705                    .map_err(|err| {
706                        PluginError::RegistrationFailed(format!(
707                            "llm sanitize response guardrail deregistration failed: {err}"
708                        ))
709                    })
710            }),
711        ));
712        Ok(())
713    }
714
715    /// Registers an LLM conditional-execution guardrail and records its rollback closure.
716    pub fn register_llm_conditional_execution_guardrail(
717        &mut self,
718        name: &str,
719        priority: i32,
720        callback: LlmConditionalFn,
721    ) -> Result<()> {
722        let qualified_name = self.qualify_name(name);
723        register_llm_conditional_execution_guardrail(&qualified_name, priority, callback).map_err(
724            |err| {
725                PluginError::RegistrationFailed(format!(
726                    "llm conditional execution guardrail: {err}"
727                ))
728            },
729        )?;
730
731        let name_owned = qualified_name;
732        self.registrations.push(PluginRegistration::new(
733            "plugin",
734            name_owned.clone(),
735            Box::new(move || {
736                deregister_llm_conditional_execution_guardrail(&name_owned)
737                    .map(|_| ())
738                    .map_err(|err| {
739                        PluginError::RegistrationFailed(format!(
740                            "llm conditional execution guardrail deregistration failed: {err}"
741                        ))
742                    })
743            }),
744        ));
745        Ok(())
746    }
747
748    /// Registers an LLM execution intercept and records its rollback closure.
749    pub fn register_llm_execution_intercept(
750        &mut self,
751        name: &str,
752        priority: i32,
753        callback: LlmExecutionFn,
754    ) -> Result<()> {
755        let qualified_name = self.qualify_name(name);
756        register_llm_execution_intercept(&qualified_name, priority, callback).map_err(|err| {
757            PluginError::RegistrationFailed(format!("llm execution intercept: {err}"))
758        })?;
759
760        let name_owned = qualified_name;
761        self.registrations.push(PluginRegistration::new(
762            "plugin",
763            name_owned.clone(),
764            Box::new(move || {
765                deregister_llm_execution_intercept(&name_owned)
766                    .map(|_| ())
767                    .map_err(|err| {
768                        PluginError::RegistrationFailed(format!(
769                            "llm execution intercept deregistration failed: {err}"
770                        ))
771                    })
772            }),
773        ));
774        Ok(())
775    }
776
777    /// Registers an LLM stream execution intercept and records its rollback closure.
778    pub fn register_llm_stream_execution_intercept(
779        &mut self,
780        name: &str,
781        priority: i32,
782        callback: LlmStreamExecutionFn,
783    ) -> Result<()> {
784        let qualified_name = self.qualify_name(name);
785        register_llm_stream_execution_intercept(&qualified_name, priority, callback).map_err(
786            |err| PluginError::RegistrationFailed(format!("llm stream execution intercept: {err}")),
787        )?;
788
789        let name_owned = qualified_name;
790        self.registrations.push(PluginRegistration::new(
791            "plugin",
792            name_owned.clone(),
793            Box::new(move || {
794                deregister_llm_stream_execution_intercept(&name_owned)
795                    .map(|_| ())
796                    .map_err(|err| {
797                        PluginError::RegistrationFailed(format!(
798                            "llm stream execution intercept deregistration failed: {err}"
799                        ))
800                    })
801            }),
802        ));
803        Ok(())
804    }
805
806    /// Registers a tool request intercept and records its rollback closure.
807    pub fn register_tool_request_intercept(
808        &mut self,
809        name: &str,
810        priority: i32,
811        break_chain: bool,
812        callback: ToolInterceptFn,
813    ) -> Result<()> {
814        let qualified_name = self.qualify_name(name);
815        register_tool_request_intercept(&qualified_name, priority, break_chain, callback).map_err(
816            |err| PluginError::RegistrationFailed(format!("tool request intercept: {err}")),
817        )?;
818
819        let name_owned = qualified_name;
820        self.registrations.push(PluginRegistration::new(
821            "plugin",
822            name_owned.clone(),
823            Box::new(move || {
824                deregister_tool_request_intercept(&name_owned)
825                    .map(|_| ())
826                    .map_err(|err| {
827                        PluginError::RegistrationFailed(format!(
828                            "tool request intercept deregistration failed: {err}"
829                        ))
830                    })
831            }),
832        ));
833        Ok(())
834    }
835
836    /// Registers a tool execution intercept and records its rollback closure.
837    pub fn register_tool_execution_intercept(
838        &mut self,
839        name: &str,
840        priority: i32,
841        callback: ToolExecutionFn,
842    ) -> Result<()> {
843        let qualified_name = self.qualify_name(name);
844        register_tool_execution_intercept(&qualified_name, priority, callback).map_err(|err| {
845            PluginError::RegistrationFailed(format!("tool execution intercept: {err}"))
846        })?;
847
848        let name_owned = qualified_name;
849        self.registrations.push(PluginRegistration::new(
850            "plugin",
851            name_owned.clone(),
852            Box::new(move || {
853                deregister_tool_execution_intercept(&name_owned)
854                    .map(|_| ())
855                    .map_err(|err| {
856                        PluginError::RegistrationFailed(format!(
857                            "tool execution intercept deregistration failed: {err}"
858                        ))
859                    })
860            }),
861        ));
862        Ok(())
863    }
864
865    /// Adds a prebuilt registration to the context.
866    pub fn add_registration(&mut self, registration: PluginRegistration) {
867        self.registrations.push(registration);
868    }
869
870    /// Extends the context with prebuilt registrations.
871    pub fn extend_registrations(&mut self, registrations: Vec<PluginRegistration>) {
872        self.registrations.extend(registrations);
873    }
874
875    /// Consumes the context and returns the recorded registrations.
876    pub fn into_registrations(self) -> Vec<PluginRegistration> {
877        self.registrations
878    }
879}
880
881/// Implemented by custom plugins that register runtime middleware.
882pub trait Plugin: Send + Sync + 'static {
883    /// Returns the unique plugin kind string.
884    fn plugin_kind(&self) -> &str;
885
886    /// Returns whether the plugin kind can appear multiple times in the config.
887    ///
888    /// Return `false` for singleton components such as the built-in adaptive
889    /// component.
890    fn allows_multiple_components(&self) -> bool {
891        true
892    }
893
894    /// Validates one plugin component config.
895    ///
896    /// Returning error-level diagnostics prevents `initialize_plugins(...)`
897    /// from activating the configuration.
898    fn validate(&self, plugin_config: &Map<String, Json>) -> Vec<ConfigDiagnostic>;
899
900    /// Validates one plugin component config using the host's global policy.
901    ///
902    /// The default preserves the validation behavior of existing custom
903    /// plugins. Plugins that emit policy-controlled diagnostics should
904    /// override this method so host validation honors non-default fields in
905    /// [`PluginConfig::policy`].
906    fn validate_with_policy(
907        &self,
908        plugin_config: &Map<String, Json>,
909        _policy: &ConfigPolicy,
910    ) -> Vec<ConfigDiagnostic> {
911        self.validate(plugin_config)
912    }
913
914    /// Registers runtime middleware/subscribers for one plugin component.
915    ///
916    /// The provided [`PluginRegistrationContext`] is component-scoped. Any
917    /// error aborts the current initialization and triggers rollback of
918    /// registrations created during the failed activation attempt.
919    fn register<'a>(
920        &'a self,
921        plugin_config: &Map<String, Json>,
922        ctx: &'a mut PluginRegistrationContext,
923    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
924}
925
926/// Registers a plugin by kind.
927///
928/// Registering the same kind twice returns [`PluginError::RegistrationFailed`].
929/// Register a plugin kind with the global plugin registry.
930///
931/// Registered plugins can then participate in validation and initialization of
932/// [`PluginConfig`] documents.
933///
934/// # Parameters
935/// - `plugin`: Plugin implementation to register.
936///
937/// # Returns
938/// A plugin [`Result`] that is `Ok(())` when the plugin kind was added
939/// to the registry.
940///
941/// # Errors
942/// Returns an error when a plugin with the same kind is already registered or
943/// when the registry lock is poisoned.
944///
945/// # Notes
946/// Registration affects future validation and initialization only.
947pub fn register_plugin(plugin: Arc<dyn Plugin>) -> Result<()> {
948    register_plugin_with_owner(plugin, PluginRegistrationOwner::External).map(|_| ())
949}
950
951pub(crate) fn register_plugin_tracked(plugin: Arc<dyn Plugin>) -> Result<u64> {
952    register_plugin_with_owner(plugin, PluginRegistrationOwner::External)
953}
954
955pub(crate) fn register_builtin_plugin(plugin: Arc<dyn Plugin>) -> Result<()> {
956    let plugin_kind = plugin.plugin_kind();
957    {
958        let guard = PLUGIN_HANDLERS.read().map_err(|err| {
959            PluginError::Internal(format!("plugin registry lock poisoned: {err}"))
960        })?;
961        if let Some(existing) = guard.get(plugin_kind) {
962            if existing.owner == PluginRegistrationOwner::Builtin {
963                return Ok(());
964            }
965            return Err(plugin_already_registered_error(
966                plugin_kind,
967                PluginRegistrationOwner::Builtin,
968            ));
969        }
970    }
971
972    register_plugin_with_owner(plugin, PluginRegistrationOwner::Builtin).map(|_| ())
973}
974
975fn plugin_already_registered_error(
976    plugin_kind: &str,
977    owner: PluginRegistrationOwner,
978) -> PluginError {
979    let ownership = if owner == PluginRegistrationOwner::Builtin {
980        "reserved builtin "
981    } else {
982        ""
983    };
984    PluginError::RegistrationFailed(format!(
985        "{ownership}plugin '{plugin_kind}' is already registered"
986    ))
987}
988
989fn register_plugin_with_owner(
990    plugin: Arc<dyn Plugin>,
991    owner: PluginRegistrationOwner,
992) -> Result<u64> {
993    let mut guard = PLUGIN_HANDLERS
994        .write()
995        .map_err(|err| PluginError::Internal(format!("plugin registry lock poisoned: {err}")))?;
996    let plugin_kind = plugin.plugin_kind().to_string();
997    if let Some(existing) = guard.get(&plugin_kind) {
998        if owner == PluginRegistrationOwner::Builtin
999            && existing.owner == PluginRegistrationOwner::Builtin
1000        {
1001            return Ok(existing.registration_id);
1002        }
1003        return Err(plugin_already_registered_error(&plugin_kind, owner));
1004    }
1005    let registration_id = NEXT_PLUGIN_REGISTRATION_ID.fetch_add(1, Ordering::Relaxed);
1006    guard.insert(
1007        plugin_kind.clone(),
1008        RegisteredPlugin {
1009            registration_id,
1010            owner,
1011            plugin,
1012        },
1013    );
1014    log::info!(
1015        target: "nemo_relay.plugin",
1016        event = "plugin_registered",
1017        plugin_kind = plugin_kind.as_str(),
1018        registration_id = registration_id;
1019        "Plugin kind registered"
1020    );
1021    Ok(registration_id)
1022}
1023
1024/// Registers core-provided plugin kinds.
1025///
1026/// Built-in plugins are available to validation and initialization without a
1027/// binding or application-specific registration call.
1028pub fn ensure_builtin_plugins_registered() -> Result<()> {
1029    let all_registered = {
1030        let guard = PLUGIN_HANDLERS.read().map_err(|err| {
1031            PluginError::Internal(format!("plugin registry lock poisoned: {err}"))
1032        })?;
1033        [
1034            crate::observability::plugin_component::OBSERVABILITY_PLUGIN_KIND,
1035            crate::plugins::nemo_guardrails::component::NEMO_GUARDRAILS_PLUGIN_KIND,
1036            crate::plugins::model_pricing::PRICING_PLUGIN_KIND,
1037        ]
1038        .iter()
1039        .all(|kind| {
1040            guard
1041                .get(*kind)
1042                .is_some_and(|plugin| plugin.owner == PluginRegistrationOwner::Builtin)
1043        })
1044    };
1045    if all_registered {
1046        return Ok(());
1047    }
1048
1049    // Registration is idempotent for genuine built-ins. Revalidate on every
1050    // call so a removed built-in is restored, a replacement is rejected, and
1051    // a corrected ownership conflict can be retried without restarting Relay.
1052    crate::observability::plugin_component::register_observability_component()?;
1053    crate::plugins::nemo_guardrails::component::register_nemo_guardrails_component()?;
1054    crate::plugins::model_pricing::register_pricing_component()
1055}
1056
1057/// Removes a previously registered plugin.
1058///
1059/// This affects future validation and initialization only. Active runtime
1060/// registrations remain until cleared or replaced.
1061///
1062/// # Parameters
1063/// - `plugin_kind`: Plugin kind to remove from the registry.
1064///
1065/// # Returns
1066/// `true` when a plugin was removed from the registry and `false` when the
1067/// kind was not registered.
1068///
1069/// # Notes
1070/// Active component registrations created by previous initialization calls are
1071/// not removed by this function.
1072pub fn deregister_plugin(plugin_kind: &str) -> bool {
1073    deregister_plugin_checked(plugin_kind).unwrap_or(false)
1074}
1075
1076pub(crate) fn deregister_plugin_checked(plugin_kind: &str) -> Result<bool> {
1077    let removed = PLUGIN_HANDLERS
1078        .write()
1079        .map(|mut guard| guard.remove(plugin_kind).is_some())
1080        .map_err(|err| PluginError::Internal(format!("plugin registry lock poisoned: {err}")))?;
1081    if removed {
1082        log::info!(
1083            target: "nemo_relay.plugin",
1084            event = "plugin_deregistered",
1085            plugin_kind = plugin_kind;
1086            "Plugin kind deregistered"
1087        );
1088    }
1089    Ok(removed)
1090}
1091
1092pub(crate) fn deregister_plugin_registration_checked(
1093    plugin_kind: &str,
1094    expected_registration_id: u64,
1095) -> Result<PluginDeregistrationOutcome> {
1096    let mut guard = PLUGIN_HANDLERS
1097        .write()
1098        .map_err(|err| PluginError::Internal(format!("plugin registry lock poisoned: {err}")))?;
1099    match guard.get(plugin_kind) {
1100        Some(plugin) if plugin.registration_id == expected_registration_id => {
1101            guard.remove(plugin_kind);
1102            log::info!(
1103                target: "nemo_relay.plugin",
1104                event = "plugin_deregistered",
1105                plugin_kind = plugin_kind,
1106                registration_id = expected_registration_id;
1107                "Plugin kind deregistered"
1108            );
1109            Ok(PluginDeregistrationOutcome::Removed)
1110        }
1111        Some(_) => Ok(PluginDeregistrationOutcome::Replaced),
1112        None => Ok(PluginDeregistrationOutcome::Missing),
1113    }
1114}
1115
1116/// Lists registered plugin kinds in sorted order.
1117///
1118/// This returns the currently registered plugin kinds without inspecting the
1119/// active runtime configuration.
1120///
1121/// # Returns
1122/// A sorted [`Vec<String>`] of registered plugin kinds.
1123///
1124/// # Notes
1125/// Disabled or inactive components still appear here when their plugin kind is
1126/// registered. An empty list is returned when built-in registration fails.
1127pub fn list_plugin_kinds() -> Vec<String> {
1128    if ensure_builtin_plugins_registered().is_err() {
1129        return Vec::new();
1130    }
1131    let mut kinds = PLUGIN_HANDLERS
1132        .read()
1133        .map(|guard| guard.keys().cloned().collect::<Vec<_>>())
1134        .unwrap_or_default();
1135    kinds.sort();
1136    kinds
1137}
1138
1139/// Looks up a registered plugin by kind.
1140///
1141/// # Parameters
1142/// - `plugin_kind`: Plugin kind to resolve.
1143///
1144/// # Returns
1145/// The registered plugin implementation for `plugin_kind`, or `None` when the
1146/// kind is unknown or built-in registration fails.
1147///
1148/// # Notes
1149/// The returned plugin is shared by [`Arc`], so callers receive a cheap clone.
1150pub fn lookup_plugin(plugin_kind: &str) -> Option<Arc<dyn Plugin>> {
1151    ensure_builtin_plugins_registered().ok()?;
1152    lookup_registered_plugin(plugin_kind)
1153}
1154
1155fn lookup_registered_plugin(plugin_kind: &str) -> Option<Arc<dyn Plugin>> {
1156    PLUGIN_HANDLERS.read().ok().and_then(|guard| {
1157        guard
1158            .get(plugin_kind)
1159            .map(|registered| Arc::clone(&registered.plugin))
1160    })
1161}
1162
1163/// Validates a plugin configuration document.
1164///
1165/// This is a pure validation pass. It does not mutate the active runtime
1166/// configuration.
1167///
1168/// # Parameters
1169/// - `config`: Plugin configuration to validate.
1170///
1171/// # Returns
1172/// A [`ConfigReport`] describing warnings and errors discovered during
1173/// validation.
1174///
1175/// # Notes
1176/// Validation checks host policy, plugin multiplicity rules, unknown component
1177/// kinds, and plugin-provided validation hooks.
1178pub fn validate_plugin_config(config: &PluginConfig) -> ConfigReport {
1179    let mut report = ConfigReport::default();
1180    if let Err(error) = ensure_builtin_plugins_registered() {
1181        report.diagnostics.push(ConfigDiagnostic {
1182            level: DiagnosticLevel::Error,
1183            code: "plugin.builtin_registration_failed".to_string(),
1184            component: None,
1185            field: None,
1186            message: format!("built-in plugin registration failed: {error}"),
1187        });
1188        return report;
1189    }
1190
1191    if config.version != 1 {
1192        push_policy_diag(
1193            &mut report.diagnostics,
1194            config.policy.unsupported_value,
1195            "plugin.unsupported_config_version",
1196            None,
1197            Some("version".to_string()),
1198            format!("plugin config version {} is unsupported", config.version),
1199        );
1200    }
1201
1202    validate_plugin_multiplicity(&mut report, config);
1203
1204    for component in &config.components {
1205        let Some(plugin) = lookup_registered_plugin(&component.kind) else {
1206            push_policy_diag(
1207                &mut report.diagnostics,
1208                config.policy.unknown_component,
1209                "plugin.unknown_component",
1210                Some(component.kind.clone()),
1211                None,
1212                format!("plugin component kind '{}' is unsupported", component.kind),
1213            );
1214            continue;
1215        };
1216        report
1217            .diagnostics
1218            .extend(plugin.validate_with_policy(&component.config, &config.policy));
1219    }
1220
1221    report
1222}
1223
1224/// Layers `right` (higher precedence) onto `left` in place.
1225///
1226/// Objects merge recursively and arrays/scalars are replaced by `right`, except:
1227///
1228/// - the top-level `components` array pairs entries by `kind` in order of appearance so
1229///   multi-instance kinds are not collapsed;
1230/// - lists inside a component's `config` concatenate with higher-precedence entries first.
1231///
1232/// Internal helper shared by plugin initialization and `plugins.toml` discovery.
1233fn layer_config(left: &mut Json, right: Json) {
1234    match (left, right) {
1235        (Json::Object(left), Json::Object(right)) => {
1236            for (key, value) in right {
1237                match (key.as_str(), left.get_mut(&key)) {
1238                    ("components", Some(existing)) => merge_plugin_components(existing, value),
1239                    (_, Some(existing)) => merge_json_value(existing, value),
1240                    (_, _) => {
1241                        left.insert(key, value);
1242                    }
1243                }
1244            }
1245        }
1246        (left, right) => *left = right,
1247    }
1248}
1249
1250/// Merges `right` components into `left` by `kind`, pairing repeated kinds positionally.
1251fn merge_plugin_components(left: &mut Json, right: Json) {
1252    let Json::Array(left_components) = left else {
1253        *left = right;
1254        return;
1255    };
1256    let Json::Array(right_components) = right else {
1257        *left = right;
1258        return;
1259    };
1260    let base_component_count = left_components.len();
1261    let mut consumed: HashMap<String, usize> = HashMap::new();
1262    for component in right_components {
1263        let Some(kind) = component_kind(&component).map(str::to_owned) else {
1264            left_components.push(component);
1265            continue;
1266        };
1267        let nth = consumed.entry(kind.clone()).or_insert(0);
1268        let slot = nth_component_by_kind(&left_components[..base_component_count], &kind, *nth);
1269        *nth += 1;
1270        match slot {
1271            Some(index) => merge_plugin_component(&mut left_components[index], component),
1272            None => left_components.push(component),
1273        }
1274    }
1275}
1276
1277/// Merges one higher-precedence component into its lower-precedence match.
1278///
1279/// Direct list fields in a component's `config` object concatenate with
1280/// higher-precedence entries first. Declared observability collections do the
1281/// same; deeper implementation-specific lists retain replacement semantics.
1282fn merge_plugin_component(existing: &mut Json, higher_priority: Json) {
1283    let is_observability = component_kind(&higher_priority).or_else(|| component_kind(existing))
1284        == Some("observability");
1285    match (existing, higher_priority) {
1286        (Json::Object(existing), Json::Object(higher_priority)) => {
1287            for (key, value) in higher_priority {
1288                match (key.as_str(), existing.get_mut(&key)) {
1289                    ("config", Some(existing_config)) => {
1290                        merge_plugin_config_value(
1291                            existing_config,
1292                            value,
1293                            &mut Vec::new(),
1294                            is_observability,
1295                        );
1296                    }
1297                    (_, Some(existing_value)) => merge_json_value(existing_value, value),
1298                    (_, None) => {
1299                        existing.insert(key, value);
1300                    }
1301                }
1302            }
1303        }
1304        (existing, higher_priority) => *existing = higher_priority,
1305    }
1306}
1307
1308/// Recursively merges plugin component config with scoped list concatenation.
1309fn merge_plugin_config_value(
1310    lower_priority: &mut Json,
1311    higher_priority: Json,
1312    path: &mut Vec<String>,
1313    is_observability: bool,
1314) {
1315    match (lower_priority, higher_priority) {
1316        (Json::Object(lower_priority), Json::Object(higher_priority)) => {
1317            for (key, value) in higher_priority {
1318                path.push(key.clone());
1319                match lower_priority.get_mut(&key) {
1320                    Some(existing) => {
1321                        merge_plugin_config_value(existing, value, path, is_observability)
1322                    }
1323                    None => {
1324                        lower_priority.insert(key, value);
1325                    }
1326                }
1327                path.pop();
1328            }
1329        }
1330        (Json::Array(lower_priority), Json::Array(mut higher_priority))
1331            if plugin_config_list_concatenates(path, is_observability) =>
1332        {
1333            higher_priority.append(lower_priority);
1334            *lower_priority = higher_priority;
1335        }
1336        (lower_priority, higher_priority) => *lower_priority = higher_priority,
1337    }
1338}
1339
1340fn plugin_config_list_concatenates(path: &[String], is_observability: bool) -> bool {
1341    path.len() == 1
1342        || (is_observability
1343            && matches!(
1344                path,
1345                [section, field]
1346                    if (section == "atof" && field == "sinks")
1347                        || (section == "opentelemetry" && field == "endpoints")
1348                        || (section == "atif" && field == "storage")
1349            ))
1350}
1351
1352/// Recursively merges `right` into a `left` JSON object; arrays and scalars are replaced.
1353fn merge_json_value(left: &mut Json, right: Json) {
1354    match (left, right) {
1355        (Json::Object(left), Json::Object(right)) => {
1356            for (key, value) in right {
1357                match left.get_mut(&key) {
1358                    Some(existing) => merge_json_value(existing, value),
1359                    None => {
1360                        left.insert(key, value);
1361                    }
1362                }
1363            }
1364        }
1365        (left, right) => *left = right,
1366    }
1367}
1368
1369fn component_kind(component: &Json) -> Option<&str> {
1370    component.get("kind").and_then(Json::as_str)
1371}
1372
1373fn nth_component_by_kind(components: &[Json], kind: &str, nth: usize) -> Option<usize> {
1374    components
1375        .iter()
1376        .enumerate()
1377        .filter(|(_index, component)| component_kind(component) == Some(kind))
1378        .nth(nth)
1379        .map(|(index, _component)| index)
1380}
1381
1382/// Returns the JSON Schema for the canonical plugin configuration document.
1383#[cfg(feature = "schema")]
1384pub fn plugin_config_schema() -> Json {
1385    serde_json::to_value(schemars::schema_for!(PluginConfig))
1386        .expect("plugin config schema should serialize")
1387}
1388
1389/// Configures the active global plugin components.
1390///
1391/// Initialization validates the supplied config, replaces the active
1392/// configuration, and rolls back partial registration on failure. If a
1393/// previous configuration was active, the host attempts to restore it when the
1394/// new activation fails.
1395///
1396/// # Parameters
1397/// - `config`: Plugin configuration to validate and activate.
1398///
1399/// # Returns
1400/// A plugin [`Result`] containing the successful [`ConfigReport`].
1401///
1402/// # Errors
1403/// Returns an error when validation fails, when plugin registration fails, or
1404/// when the previous configuration cannot be restored after a failed replace.
1405///
1406/// # Notes
1407/// Initialization is replace-with-rollback: the previous active configuration
1408/// is removed before the new configuration is activated.
1409#[doc(hidden)]
1410pub async fn initialize_plugins_exact(config: PluginConfig) -> Result<ConfigReport> {
1411    initialize_plugins_with_diagnostics(config, Vec::new()).await
1412}
1413
1414async fn initialize_plugins_with_diagnostics(
1415    config: PluginConfig,
1416    diagnostics: Vec<ConfigDiagnostic>,
1417) -> Result<ConfigReport> {
1418    run_owned_plugin_mutation("plugin initialization", move || async move {
1419        let lease = LegacyPluginMutationLease::acquire()?;
1420        let rollback_failures = Arc::new(Mutex::new(Vec::new()));
1421        let initialization = tokio::spawn(initialize_plugins_exact_inner(
1422            config,
1423            Some(Arc::clone(&rollback_failures)),
1424            diagnostics,
1425        ))
1426        .await
1427        .map_err(|error| {
1428            PluginError::Internal(format!("plugin initialization task failed: {error}"))
1429        });
1430        let result = initialization.and_then(|result| result);
1431        let failures = rollback_failures
1432            .lock()
1433            .map(|failures| failures.clone())
1434            .unwrap_or_else(|lock_error| {
1435                vec![format!("rollback failure lock poisoned: {lock_error}")]
1436            });
1437        match result {
1438            Err(error) if !failures.is_empty() => {
1439                std::mem::forget(lease);
1440                Err(PluginError::RegistrationFailed(format!(
1441                    concat!(
1442                        "{}; initialization rollback was incomplete: {}; plugin ",
1443                        "configuration mutations are disabled for this process because callbacks ",
1444                        "may remain registered"
1445                    ),
1446                    error,
1447                    failures.join("; ")
1448                )))
1449            }
1450            result => result,
1451        }
1452    })
1453    .await
1454}
1455
1456pub(crate) async fn run_owned_plugin_mutation<T, F, Fut>(
1457    operation_name: &'static str,
1458    operation: F,
1459) -> Result<T>
1460where
1461    T: Send + 'static,
1462    F: FnOnce() -> Fut + Send + 'static,
1463    Fut: Future<Output = Result<T>> + Send + 'static,
1464{
1465    if IN_PLUGIN_MUTATION_EXECUTOR.get() {
1466        return operation().await;
1467    }
1468
1469    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
1470    plugin_mutation_executor()?
1471        .send(Box::pin(async move {
1472            let result = tokio::spawn(operation())
1473                .await
1474                .map_err(|error| {
1475                    PluginError::Internal(format!("{operation_name} task failed: {error}"))
1476                })
1477                .and_then(|result| result);
1478            let _ = result_tx.send(result);
1479        }))
1480        .map_err(|_| {
1481            PluginError::Internal(format!(
1482                "failed to queue {operation_name}: executor stopped"
1483            ))
1484        })?;
1485    result_rx.await.map_err(|_| {
1486        PluginError::Internal(format!(
1487            "{operation_name} task stopped before returning a result"
1488        ))
1489    })?
1490}
1491
1492fn plugin_mutation_executor() -> Result<&'static PluginMutationSender> {
1493    if let Some(sender) = PLUGIN_MUTATION_EXECUTOR.get() {
1494        return Ok(sender);
1495    }
1496
1497    let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<PluginMutationJob>();
1498    let (startup_tx, startup_rx) = std::sync::mpsc::sync_channel(1);
1499    std::thread::Builder::new()
1500        .name("nemo-relay-plugin-host".into())
1501        .spawn(move || {
1502            let runtime = match tokio::runtime::Builder::new_current_thread()
1503                .enable_all()
1504                .build()
1505            {
1506                Ok(runtime) => runtime,
1507                Err(error) => {
1508                    let _ = startup_tx.send(Err(error.to_string()));
1509                    return;
1510                }
1511            };
1512            let _ = startup_tx.send(Ok(()));
1513            IN_PLUGIN_MUTATION_EXECUTOR.set(true);
1514            runtime.block_on(async move {
1515                while let Some(job) = receiver.recv().await {
1516                    job.await;
1517                }
1518            });
1519        })
1520        .map_err(|error| {
1521            PluginError::Internal(format!("failed to start plugin host executor: {error}"))
1522        })?;
1523    startup_rx
1524        .recv()
1525        .map_err(|error| {
1526            PluginError::Internal(format!(
1527                "plugin host executor stopped during startup: {error}"
1528            ))
1529        })?
1530        .map_err(|error| {
1531            PluginError::Internal(format!("failed to start plugin host runtime: {error}"))
1532        })?;
1533
1534    Ok(PLUGIN_MUTATION_EXECUTOR.get_or_init(|| sender))
1535}
1536
1537pub(crate) async fn initialize_plugins_exact_for_host(
1538    config: PluginConfig,
1539    owner_id: u64,
1540    rollback_failures: Arc<Mutex<Vec<String>>>,
1541    diagnostics: Vec<ConfigDiagnostic>,
1542) -> Result<ConfigReport> {
1543    verify_plugin_host_owner(owner_id)?;
1544    initialize_plugins_exact_inner(config, Some(rollback_failures), diagnostics).await
1545}
1546
1547async fn initialize_plugins_exact_inner(
1548    config: PluginConfig,
1549    rollback_failures: Option<Arc<Mutex<Vec<String>>>>,
1550    diagnostics: Vec<ConfigDiagnostic>,
1551) -> Result<ConfigReport> {
1552    let enabled_component_count = config
1553        .components
1554        .iter()
1555        .filter(|component| component.enabled)
1556        .count();
1557    log::info!(
1558        target: "nemo_relay.plugin",
1559        event = "plugin_configuration_activation_started",
1560        component_count = enabled_component_count;
1561        "Plugin configuration activation started"
1562    );
1563    let mut report = ConfigReport {
1564        diagnostics,
1565        ..ConfigReport::default()
1566    };
1567    report
1568        .diagnostics
1569        .extend(validate_plugin_config(&config).diagnostics);
1570    if report.has_errors() {
1571        return Err(PluginError::InvalidConfig(join_error_messages(&report)));
1572    }
1573
1574    let previous = {
1575        let mut guard = ACTIVE_PLUGIN_CONFIGURATION.lock().map_err(|err| {
1576            PluginError::Internal(format!("active plugin configuration lock poisoned: {err}"))
1577        })?;
1578        guard.take()
1579    };
1580
1581    if let Some(mut previous_state) = previous {
1582        // Keep the previous report installed while teardown callbacks run so
1583        // runtime diagnostics emitted by teardown remain observable.
1584        {
1585            let mut guard = ACTIVE_PLUGIN_CONFIGURATION.lock().map_err(|err| {
1586                PluginError::Internal(format!("active plugin configuration lock poisoned: {err}"))
1587            })?;
1588            *guard = Some(ActivePluginConfiguration {
1589                config: previous_state.config.clone(),
1590                report: previous_state.report.clone(),
1591                registrations: Vec::new(),
1592            });
1593        }
1594        let teardown = rollback_registrations_checked(&mut previous_state.registrations);
1595        let teardown_report = ACTIVE_PLUGIN_CONFIGURATION
1596            .lock()
1597            .map_err(|err| {
1598                PluginError::Internal(format!("active plugin configuration lock poisoned: {err}"))
1599            })?
1600            .take()
1601            .map(|state| state.report);
1602        if !teardown.errors.is_empty() {
1603            if let Some(report) =
1604                teardown_report.filter(|report| !report.runtime_diagnostics.is_empty())
1605                && let Ok(mut guard) = LAST_FAILED_RUNTIME_DIAGNOSTICS_REPORT.lock()
1606            {
1607                *guard = Some(report);
1608            }
1609            if !teardown.callbacks_cleared {
1610                record_rollback_failures(rollback_failures.as_ref(), teardown.errors.clone());
1611            }
1612            return Err(PluginError::RegistrationFailed(format!(
1613                "previous plugin configuration could not be cleared: {}",
1614                teardown.errors.join("; ")
1615            )));
1616        }
1617        match initialize_plugin_components_catching_panics(
1618            config.clone(),
1619            rollback_failures.clone(),
1620        )
1621        .await
1622        {
1623            Ok(registrations) => {
1624                store_active_plugin_configuration(config, report.clone(), registrations)?;
1625                log::info!(
1626                    target: "nemo_relay.plugin",
1627                    event = "plugin_configuration_replaced",
1628                    component_count = enabled_component_count;
1629                    "Plugin configuration replaced"
1630                );
1631                Ok(report)
1632            }
1633            Err(err) => match initialize_plugin_components_catching_panics(
1634                previous_state.config.clone(),
1635                rollback_failures.clone(),
1636            )
1637            .await
1638            {
1639                Ok(registrations) => {
1640                    store_active_plugin_configuration(
1641                        previous_state.config,
1642                        previous_state.report,
1643                        registrations,
1644                    )?;
1645                    log::warn!(
1646                        target: "nemo_relay.plugin",
1647                        event = "plugin_configuration_restored",
1648                        recovery = "previous_configuration";
1649                        "Plugin activation failed; previous configuration restored"
1650                    );
1651                    Err(err)
1652                }
1653                Err(restore_err) => {
1654                    log::error!(
1655                        target: "nemo_relay.plugin",
1656                        event = "plugin_rollback_failed",
1657                        recovery = "previous_configuration";
1658                        "Plugin activation failed and the previous configuration could not be restored"
1659                    );
1660                    Err(PluginError::RegistrationFailed(format!(
1661                        "{err}; previous plugin configuration could not be restored: {restore_err}"
1662                    )))
1663                }
1664            },
1665        }
1666    } else {
1667        let registrations =
1668            initialize_plugin_components_catching_panics(config.clone(), rollback_failures).await?;
1669        store_active_plugin_configuration(config, report.clone(), registrations)?;
1670        log::info!(
1671            target: "nemo_relay.plugin",
1672            event = "plugin_configuration_activated",
1673            component_count = enabled_component_count;
1674            "Plugin configuration activated"
1675        );
1676        Ok(report)
1677    }
1678}
1679
1680async fn initialize_plugin_components_catching_panics(
1681    config: PluginConfig,
1682    rollback_failures: Option<Arc<Mutex<Vec<String>>>>,
1683) -> Result<Vec<PluginRegistration>> {
1684    tokio::spawn(async move { initialize_plugin_components(&config, rollback_failures).await })
1685        .await
1686        .map_err(|error| {
1687            PluginError::Internal(format!(
1688                "plugin component initialization task failed: {error}"
1689            ))
1690        })?
1691}
1692
1693/// Validates and activates `config` layered on top of the discovered
1694/// `plugins.toml` configuration, so a direct integration sees the same file
1695/// layering as the gateway. Each file's schema version is validated before
1696/// layering. Declaring a component in `config` applies its `enabled` value,
1697/// while default policy values inherit from discovered files and component
1698/// `config` bodies merge field-by-field. The resolved configuration and
1699/// diagnostics are passed to the shared `initialize_plugins_with_diagnostics`
1700/// helper. Call [`initialize_plugins_exact`] directly when `config` is already
1701/// fully resolved and every value must be applied exactly.
1702pub async fn initialize_plugins(config: PluginConfig) -> Result<ConfigReport> {
1703    let resolved = resolve_plugin_config(config)?;
1704    initialize_plugins_with_diagnostics(resolved.config, resolved.diagnostics).await
1705}
1706
1707/// Layers `config` over the default discovered `plugins.toml` files.
1708///
1709/// This is crate-visible so owned dynamic-plugin activation can use the same
1710/// one-time configuration resolution as regular harness-native initialization.
1711pub(crate) fn resolve_plugin_config(config: PluginConfig) -> Result<ResolvedPluginConfig> {
1712    let discovered = resolve_default_file_plugin_config()?;
1713    resolve_programmatic_plugin_config(discovered, config)
1714}
1715
1716fn resolve_programmatic_plugin_config(
1717    discovered: DiscoveredPluginConfig,
1718    config: PluginConfig,
1719) -> Result<ResolvedPluginConfig> {
1720    let mut diagnostics = inherited_plugin_config_diagnostics(&discovered.sources);
1721    diagnostics.extend(programmatic_enable_override_diagnostics(
1722        &discovered.value,
1723        &discovered.enabled_sources,
1724        &config,
1725    ));
1726    let mut base = discovered.value;
1727    layer_config(&mut base, plugin_config_overlay_value(&config)?);
1728    Ok(ResolvedPluginConfig {
1729        config: serde_json::from_value(base)?,
1730        diagnostics,
1731    })
1732}
1733
1734pub(crate) struct ResolvedPluginConfig {
1735    pub(crate) config: PluginConfig,
1736    pub(crate) diagnostics: Vec<ConfigDiagnostic>,
1737}
1738
1739/// Serializes a typed configuration as a discovery overlay.
1740///
1741/// A [`PluginConfig`] cannot record whether a default-valued field was supplied
1742/// explicitly or filled by serde. Treating those defaults as overlay values
1743/// would mask discovered file settings on every library initialization. A
1744/// declared component is an activation intent, so its `enabled` value remains
1745/// in the overlay. Exact callers bypass discovery through
1746/// [`initialize_plugins_exact`].
1747fn plugin_config_overlay_value(config: &PluginConfig) -> Result<Json> {
1748    let mut overlay = serde_json::to_value(config)?;
1749    let Json::Object(root) = &mut overlay else {
1750        return Ok(overlay);
1751    };
1752
1753    if config.version == default_plugin_config_version() {
1754        root.remove("version");
1755    }
1756
1757    remove_default_policy_overlay(root, &config.policy);
1758
1759    Ok(overlay)
1760}
1761
1762fn remove_default_policy_overlay(root: &mut Map<String, Json>, config: &ConfigPolicy) {
1763    let Some(Json::Object(policy)) = root.get_mut("policy") else {
1764        return;
1765    };
1766    let defaults = ConfigPolicy::default();
1767    for (field, is_default) in [
1768        (
1769            "unknown_component",
1770            config.unknown_component == defaults.unknown_component,
1771        ),
1772        (
1773            "unknown_field",
1774            config.unknown_field == defaults.unknown_field,
1775        ),
1776        (
1777            "unsupported_value",
1778            config.unsupported_value == defaults.unsupported_value,
1779        ),
1780    ] {
1781        if is_default {
1782            policy.remove(field);
1783        }
1784    }
1785    if policy.is_empty() {
1786        root.remove("policy");
1787    }
1788}
1789
1790/// Resolves the default `plugins.toml` layering into one JSON document, or an
1791/// empty object when no plugin file exists.
1792fn resolve_default_file_plugin_config() -> Result<DiscoveredPluginConfig> {
1793    let paths =
1794        default_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir());
1795    let documents = read_plugin_config_files(paths)?;
1796    resolve_discovered_plugin_config(documents)
1797}
1798
1799fn resolve_discovered_plugin_config(
1800    documents: Vec<(PathBuf, Json)>,
1801) -> Result<DiscoveredPluginConfig> {
1802    let enabled_sources = component_enabled_sources(&documents);
1803    let (value, sources) = merge_plugin_config_documents(documents)?
1804        .unwrap_or_else(|| (Json::Object(Map::new()), Vec::new()));
1805    Ok(DiscoveredPluginConfig {
1806        value,
1807        enabled_sources,
1808        sources,
1809    })
1810}
1811
1812struct DiscoveredPluginConfig {
1813    value: Json,
1814    enabled_sources: HashMap<String, ComponentEnabledSource>,
1815    sources: Vec<PathBuf>,
1816}
1817
1818struct ComponentEnabledSource {
1819    enabled: bool,
1820    path: PathBuf,
1821}
1822
1823use std::path::{Path, PathBuf};
1824
1825/// Reads, parses, and merges the `plugins.toml` files at `paths` (lowest
1826/// precedence first) into one JSON document with its source paths, or `None`
1827/// when none exist. Internal: `pub` only for cross-crate reuse by the gateway.
1828#[doc(hidden)]
1829pub fn load_plugin_config_files<I>(paths: I) -> Result<Option<(Json, Vec<PathBuf>)>>
1830where
1831    I: IntoIterator<Item = PathBuf>,
1832{
1833    merge_plugin_config_documents(read_plugin_config_files(paths)?)
1834}
1835
1836fn read_plugin_config_files<I>(paths: I) -> Result<Vec<(PathBuf, Json)>>
1837where
1838    I: IntoIterator<Item = PathBuf>,
1839{
1840    let mut documents = Vec::new();
1841    for path in deduplicate_plugin_config_paths(paths) {
1842        if !path.exists() {
1843            continue;
1844        }
1845        let raw = std::fs::read_to_string(&path).map_err(|err| {
1846            PluginError::InvalidConfig(format!("failed to read {}: {err}", path.display()))
1847        })?;
1848        let parsed = raw.parse::<toml::Table>().map_err(|err| {
1849            PluginError::InvalidConfig(format!("invalid plugin TOML in {}: {err}", path.display()))
1850        })?;
1851        documents.push((path, serde_json::to_value(parsed)?));
1852    }
1853    Ok(documents)
1854}
1855
1856fn component_enabled_sources(
1857    documents: &[(PathBuf, Json)],
1858) -> HashMap<String, ComponentEnabledSource> {
1859    let mut sources = HashMap::new();
1860    for (path, document) in documents {
1861        let Some(components) = document.get("components").and_then(Json::as_array) else {
1862            continue;
1863        };
1864        for component in components {
1865            let Some(kind) = component_kind(component) else {
1866                continue;
1867            };
1868            if let Some(enabled) = component.get("enabled").and_then(Json::as_bool) {
1869                sources.insert(
1870                    kind.to_string(),
1871                    ComponentEnabledSource {
1872                        enabled,
1873                        path: path.clone(),
1874                    },
1875                );
1876            }
1877        }
1878    }
1879    sources
1880}
1881
1882fn inherited_plugin_config_diagnostics(sources: &[PathBuf]) -> Vec<ConfigDiagnostic> {
1883    sources
1884        .iter()
1885        .map(|source| {
1886            let source = source.display().to_string();
1887            log::warn!(
1888                target: "nemo_relay.plugin",
1889                event = "plugin_configuration_inherited",
1890                config_path = source.as_str();
1891                "Inherited plugin configuration from discovered file"
1892            );
1893            ConfigDiagnostic {
1894                level: DiagnosticLevel::Warning,
1895                code: "plugin.configuration_inherited".to_string(),
1896                component: None,
1897                field: None,
1898                message: format!("inherited plugin configuration from discovered file: {source}"),
1899            }
1900        })
1901        .collect()
1902}
1903
1904fn programmatic_enable_override_diagnostics(
1905    discovered: &Json,
1906    enabled_sources: &HashMap<String, ComponentEnabledSource>,
1907    programmatic: &PluginConfig,
1908) -> Vec<ConfigDiagnostic> {
1909    let Some(discovered_components) = discovered.get("components").and_then(Json::as_array) else {
1910        return Vec::new();
1911    };
1912    let mut consumed = HashMap::new();
1913    let mut diagnostics = Vec::new();
1914    for component in &programmatic.components {
1915        let nth = consumed.entry(component.kind.as_str()).or_insert(0usize);
1916        let discovered_component =
1917            nth_component_by_kind(discovered_components, &component.kind, *nth)
1918                .and_then(|index| discovered_components.get(index));
1919        *nth += 1;
1920        let discovered_enabled = discovered_component
1921            .and_then(|component| component.get("enabled"))
1922            .and_then(Json::as_bool);
1923        let file_disabled = discovered_enabled == Some(false)
1924            || (discovered_enabled.is_none()
1925                && enabled_sources
1926                    .get(&component.kind)
1927                    .is_some_and(|source| !source.enabled));
1928        if !component.enabled || !file_disabled {
1929            continue;
1930        }
1931
1932        let source = enabled_sources
1933            .get(&component.kind)
1934            .map(|source| format!(" from {}", source.path.display()))
1935            .unwrap_or_default();
1936        diagnostics.push(ConfigDiagnostic {
1937            level: DiagnosticLevel::Warning,
1938            code: "plugin.component_reenabled".to_string(),
1939            component: Some(component.kind.clone()),
1940            field: Some("enabled".to_string()),
1941            message: format!(
1942                "programmatic configuration enabled plugin component '{}' and overrode enabled = false{source}",
1943                component.kind
1944            ),
1945        });
1946    }
1947    diagnostics
1948}
1949
1950/// Removes physical duplicates while preserving the highest-precedence path.
1951/// Internal: `pub` only for cross-crate reuse by the gateway.
1952#[doc(hidden)]
1953pub fn deduplicate_plugin_config_paths<I>(paths: I) -> Vec<PathBuf>
1954where
1955    I: IntoIterator<Item = PathBuf>,
1956{
1957    let paths = paths.into_iter().collect::<Vec<_>>();
1958    let mut seen = HashSet::new();
1959    let mut unique = Vec::with_capacity(paths.len());
1960    for path in paths.into_iter().rev() {
1961        let identity = path.canonicalize().unwrap_or_else(|_| path.clone());
1962        if seen.insert(identity) {
1963            unique.push(path);
1964        }
1965    }
1966    unique.reverse();
1967    unique
1968}
1969
1970/// Merges pre-parsed `plugins.toml` JSON documents (lowest precedence first) using the canonical
1971/// plugin-config layering rules. Internal: `pub` only so the CLI can preprocess dynamic-plugin
1972/// refs while still sharing one merge semantics implementation with core.
1973#[doc(hidden)]
1974pub fn merge_plugin_config_documents<I>(documents: I) -> Result<Option<(Json, Vec<PathBuf>)>>
1975where
1976    I: IntoIterator<Item = (PathBuf, Json)>,
1977{
1978    let mut merged = Json::Object(Map::new());
1979    let mut sources = Vec::new();
1980    for (path, mut document) in documents {
1981        validate_plugin_config_version(&path, &document)?;
1982        validate_unique_component_kinds(&path, &document)?;
1983
1984        filter_disabled_plugin_components(&mut document);
1985        layer_config(&mut merged, document);
1986        sources.push(path);
1987    }
1988    Ok((!sources.is_empty()).then_some((merged, sources)))
1989}
1990
1991/// Removes disabled components from one discovered plugin document before layering.
1992fn filter_disabled_plugin_components(document: &mut Json) {
1993    let Some(components) = document.get_mut("components").and_then(Json::as_array_mut) else {
1994        return;
1995    };
1996    components.retain(|component| component.get("enabled").and_then(Json::as_bool) != Some(false));
1997}
1998
1999/// Rejects a file with an unsupported top-level plugin config version before layering can
2000/// overwrite it with a higher-precedence source or typed default.
2001fn validate_plugin_config_version(path: &Path, document: &Json) -> Result<()> {
2002    let Some(raw_version) = document.get("version") else {
2003        return Ok(());
2004    };
2005    let version = serde_json::from_value::<u32>(raw_version.clone()).map_err(|error| {
2006        PluginError::InvalidConfig(format!(
2007            "invalid plugin config version in {}: {error}",
2008            path.display()
2009        ))
2010    })?;
2011    if version == default_plugin_config_version() {
2012        return Ok(());
2013    }
2014    Err(PluginError::InvalidConfig(format!(
2015        "plugin config version {version} in {} is unsupported; expected {}",
2016        path.display(),
2017        default_plugin_config_version()
2018    )))
2019}
2020
2021/// Rejects a single file that declares the same component `kind` more than once.
2022fn validate_unique_component_kinds(path: &Path, document: &Json) -> Result<()> {
2023    let Some(components) = document.get("components").and_then(Json::as_array) else {
2024        return Ok(());
2025    };
2026    let mut seen = HashSet::new();
2027    let mut duplicates = Vec::new();
2028    for component in components {
2029        if let Some(kind) = component_kind(component)
2030            && !seen.insert(kind)
2031        {
2032            duplicates.push(kind.to_string());
2033        }
2034    }
2035    if duplicates.is_empty() {
2036        return Ok(());
2037    }
2038    duplicates.sort();
2039    duplicates.dedup();
2040    Err(PluginError::InvalidConfig(format!(
2041        "duplicate plugin component kind in {}: {}; declare each kind once per plugins.toml",
2042        path.display(),
2043        duplicates.join(", ")
2044    )))
2045}
2046
2047/// Default `plugins.toml` search path (lowest precedence first): user, nearest
2048/// project file, then system file — mirroring the gateway's discovery. `pub` only
2049/// for cross-crate reuse by the gateway.
2050#[doc(hidden)]
2051pub fn default_plugin_config_paths(cwd: Option<&Path>, user_dir: Option<PathBuf>) -> Vec<PathBuf> {
2052    let mut paths = Vec::new();
2053    if let Some(dir) = user_dir {
2054        paths.push(dir.join("plugins.toml"));
2055    }
2056    if let Some(cwd) = cwd
2057        && let Some(project) = nearest_project_plugin_config(cwd)
2058    {
2059        paths.push(project);
2060    }
2061    paths.push(PathBuf::from("/etc/nemo-relay/plugins.toml"));
2062    paths
2063}
2064
2065/// Walks upward from `start` for the nearest `.nemo-relay/plugins.toml`. `pub`
2066/// only for cross-crate reuse by the gateway.
2067#[doc(hidden)]
2068pub fn nearest_project_plugin_config(start: &Path) -> Option<PathBuf> {
2069    start
2070        .ancestors()
2071        .map(|ancestor| ancestor.join(".nemo-relay").join("plugins.toml"))
2072        .find(|path| path.exists())
2073}
2074
2075/// Resolves the nemo-relay user config directory from `XDG_CONFIG_HOME`, then
2076/// `HOME`/`USERPROFILE`. `pub` only for cross-crate reuse by the gateway.
2077#[doc(hidden)]
2078pub fn user_config_dir() -> Option<PathBuf> {
2079    if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") {
2080        return Some(PathBuf::from(base).join("nemo-relay"));
2081    }
2082    std::env::var_os("HOME")
2083        .or_else(|| std::env::var_os("USERPROFILE"))
2084        .map(|home| PathBuf::from(home).join(".config/nemo-relay"))
2085}
2086
2087/// Deregisters and clears all configured plugin components.
2088///
2089/// Registered plugin kinds remain available for future validation and
2090/// initialization.
2091///
2092/// # Returns
2093/// A plugin [`Result`] that is `Ok(())` when the active configuration
2094/// has been cleared.
2095///
2096/// # Errors
2097/// Returns an error when the active configuration lock is poisoned.
2098///
2099/// # Notes
2100/// Clearing active configuration does not remove plugin kinds from the global
2101/// registry.
2102pub fn clear_plugin_configuration() -> Result<()> {
2103    let lease = LegacyPluginMutationLease::acquire()?;
2104    let outcome = clear_plugin_configuration_inner();
2105    if !outcome.callbacks_cleared {
2106        // Deregistration callbacks are single-use. If one failed, the process
2107        // can no longer prove that replacing configuration is safe.
2108        std::mem::forget(lease);
2109        log::error!(
2110            target: "nemo_relay.plugin",
2111            event = "plugin_cleanup_failed",
2112            callbacks_cleared = false;
2113            "Plugin configuration cleanup was incomplete"
2114        );
2115        return Err(PluginError::RegistrationFailed(format!(
2116            concat!(
2117                "{}; plugin configuration mutations are disabled for this process because ",
2118                "callbacks may remain registered"
2119            ),
2120            outcome
2121                .result
2122                .err()
2123                .map(|error| error.to_string())
2124                .unwrap_or_else(|| "plugin teardown was incomplete".into())
2125        )));
2126    }
2127    if outcome.result.is_ok() {
2128        log::info!(
2129            target: "nemo_relay.plugin",
2130            event = "plugin_configuration_cleared";
2131            "Plugin configuration cleared"
2132        );
2133    }
2134    outcome.result
2135}
2136
2137pub(crate) fn clear_plugin_configuration_for_host(owner_id: u64) -> PluginHostClearOutcome {
2138    if let Err(error) = verify_plugin_host_owner(owner_id) {
2139        return PluginHostClearOutcome {
2140            result: Err(error),
2141            callbacks_cleared: false,
2142        };
2143    }
2144    clear_plugin_configuration_inner()
2145}
2146
2147pub(crate) struct PluginHostClearOutcome {
2148    pub(crate) result: Result<()>,
2149    pub(crate) callbacks_cleared: bool,
2150}
2151
2152fn clear_plugin_configuration_inner() -> PluginHostClearOutcome {
2153    let flush_error = crate::api::runtime::subscriber_dispatcher::flush_queued_subscribers()
2154        .err()
2155        .map(|error| error.to_string());
2156    let mut registrations = {
2157        let mut guard = match ACTIVE_PLUGIN_CONFIGURATION.lock() {
2158            Ok(guard) => guard,
2159            Err(err) => {
2160                return PluginHostClearOutcome {
2161                    result: Err(PluginError::Internal(format!(
2162                        "active plugin configuration lock poisoned: {err}"
2163                    ))),
2164                    callbacks_cleared: false,
2165                };
2166            }
2167        };
2168        guard
2169            .as_mut()
2170            .map(|state| std::mem::take(&mut state.registrations))
2171    };
2172    // Keep the report installed while callbacks run so runtime diagnostics
2173    // emitted by teardown work can be recorded against it.
2174    let deregistration = registrations
2175        .as_mut()
2176        .map(rollback_registrations_checked)
2177        .unwrap_or_default();
2178    let teardown_report = match ACTIVE_PLUGIN_CONFIGURATION.lock() {
2179        Ok(mut guard) => guard.take().map(|state| state.report),
2180        Err(err) => {
2181            return PluginHostClearOutcome {
2182                result: Err(PluginError::Internal(format!(
2183                    "active plugin configuration lock poisoned: {err}"
2184                ))),
2185                callbacks_cleared: false,
2186            };
2187        }
2188    };
2189    let deregistration_error = (!deregistration.errors.is_empty()).then(|| {
2190        PluginError::RegistrationFailed(format!(
2191            "plugin teardown failed: {}",
2192            deregistration.errors.join("; ")
2193        ))
2194    });
2195    let result = match (flush_error, deregistration_error) {
2196        (None, None) => Ok(()),
2197        (Some(flush), None) => Err(PluginError::Internal(flush)),
2198        (None, Some(deregister)) => Err(deregister),
2199        (Some(flush), Some(deregister)) => Err(PluginError::RegistrationFailed(format!(
2200            "{deregister}; subscriber flush also failed: {flush}"
2201        ))),
2202    };
2203    if result.is_ok() {
2204        if let Ok(mut guard) = LAST_FAILED_RUNTIME_DIAGNOSTICS_REPORT.lock() {
2205            *guard = None;
2206        }
2207    } else if let Some(report) =
2208        teardown_report.filter(|report| !report.runtime_diagnostics.is_empty())
2209        && let Ok(mut guard) = LAST_FAILED_RUNTIME_DIAGNOSTICS_REPORT.lock()
2210    {
2211        *guard = Some(report);
2212    }
2213    PluginHostClearOutcome {
2214        result,
2215        callbacks_cleared: deregistration.callbacks_cleared,
2216    }
2217}
2218
2219pub(crate) fn plugin_configuration_is_active() -> Result<bool> {
2220    ACTIVE_PLUGIN_CONFIGURATION
2221        .lock()
2222        .map(|guard| guard.is_some())
2223        .map_err(|err| {
2224            PluginError::Internal(format!("active plugin configuration lock poisoned: {err}"))
2225        })
2226}
2227
2228pub(crate) struct PluginHostLease {
2229    owner_id: u64,
2230}
2231
2232impl PluginHostLease {
2233    pub(crate) fn owner_id(&self) -> u64 {
2234        self.owner_id
2235    }
2236}
2237
2238impl Drop for PluginHostLease {
2239    fn drop(&mut self) {
2240        if let Ok(mut owner) = PLUGIN_MUTATION_OWNER.lock()
2241            && *owner == PluginMutationOwner::Host(self.owner_id)
2242        {
2243            *owner = PluginMutationOwner::Idle;
2244        }
2245    }
2246}
2247
2248pub(crate) fn acquire_plugin_host_lease() -> Result<PluginHostLease> {
2249    let mut owner = PLUGIN_MUTATION_OWNER.lock().map_err(|err| {
2250        PluginError::Internal(format!("plugin mutation owner lock poisoned: {err}"))
2251    })?;
2252    if *owner != PluginMutationOwner::Idle {
2253        return Err(plugin_mutation_conflict(*owner));
2254    }
2255    if plugin_configuration_is_active()? {
2256        return Err(PluginError::Conflict(
2257            concat!(
2258                "a static plugin configuration is already active; to combine static and ",
2259                "dynamic plugins, provide the static components as the base configuration to ",
2260                "dynamic plugin activation before calling plugin initialization"
2261            )
2262            .into(),
2263        ));
2264    }
2265    let owner_id = NEXT_PLUGIN_HOST_OWNER_ID.fetch_add(1, Ordering::Relaxed);
2266    *owner = PluginMutationOwner::Host(owner_id);
2267    Ok(PluginHostLease { owner_id })
2268}
2269
2270fn verify_plugin_host_owner(owner_id: u64) -> Result<()> {
2271    let owner = PLUGIN_MUTATION_OWNER.lock().map_err(|err| {
2272        PluginError::Internal(format!("plugin mutation owner lock poisoned: {err}"))
2273    })?;
2274    if *owner == PluginMutationOwner::Host(owner_id) {
2275        Ok(())
2276    } else {
2277        Err(PluginError::Conflict(
2278            "dynamic plugin host no longer owns plugin configuration".into(),
2279        ))
2280    }
2281}
2282
2283struct LegacyPluginMutationLease;
2284
2285impl LegacyPluginMutationLease {
2286    fn acquire() -> Result<Self> {
2287        let mut owner = PLUGIN_MUTATION_OWNER.lock().map_err(|err| {
2288            PluginError::Internal(format!("plugin mutation owner lock poisoned: {err}"))
2289        })?;
2290        if *owner != PluginMutationOwner::Idle {
2291            return Err(plugin_mutation_conflict(*owner));
2292        }
2293        *owner = PluginMutationOwner::Legacy;
2294        Ok(Self)
2295    }
2296}
2297
2298impl Drop for LegacyPluginMutationLease {
2299    fn drop(&mut self) {
2300        if let Ok(mut owner) = PLUGIN_MUTATION_OWNER.lock()
2301            && *owner == PluginMutationOwner::Legacy
2302        {
2303            *owner = PluginMutationOwner::Idle;
2304        }
2305    }
2306}
2307
2308fn plugin_mutation_conflict(owner: PluginMutationOwner) -> PluginError {
2309    let message = match owner {
2310        PluginMutationOwner::Idle => "plugin configuration is available",
2311        PluginMutationOwner::Legacy => "another plugin configuration mutation is in progress",
2312        PluginMutationOwner::Host(_) => {
2313            "plugin configuration is owned by an active dynamic plugin host"
2314        }
2315    };
2316    PluginError::Conflict(message.into())
2317}
2318
2319/// Returns the active plugin report or a report retained after a failed teardown.
2320///
2321/// `None` indicates that no plugin configuration is active and no failed
2322/// teardown report is retained.
2323///
2324/// # Returns
2325/// The active [`ConfigReport`], or the report containing runtime diagnostics
2326/// from the last failed teardown.
2327///
2328/// # Notes
2329/// This is a snapshot of the last successful activation and does not re-run
2330/// validation.
2331pub fn active_plugin_report() -> Option<ConfigReport> {
2332    let active_report = ACTIVE_PLUGIN_CONFIGURATION
2333        .lock()
2334        .ok()
2335        .and_then(|guard| guard.as_ref().map(|state| state.report.clone()));
2336    active_report.or_else(|| {
2337        LAST_FAILED_RUNTIME_DIAGNOSTICS_REPORT
2338            .lock()
2339            .ok()
2340            .and_then(|guard| guard.clone())
2341    })
2342}
2343
2344/// Record a bounded runtime diagnostic against the active plugin report.
2345pub fn record_active_plugin_runtime_diagnostic(diagnostic: RuntimeDiagnostic) {
2346    let Ok(mut guard) = ACTIVE_PLUGIN_CONFIGURATION.lock() else {
2347        return;
2348    };
2349    let Some(state) = guard.as_mut() else {
2350        return;
2351    };
2352    if let Some(existing) = state
2353        .report
2354        .runtime_diagnostics
2355        .iter_mut()
2356        .find(|existing| {
2357            existing.code == diagnostic.code
2358                && existing.component == diagnostic.component
2359                && existing.field == diagnostic.field
2360        })
2361    {
2362        existing.message = diagnostic.message;
2363        existing.session_id = diagnostic.session_id;
2364        existing.count += 1;
2365    } else {
2366        state.report.runtime_diagnostics.push(diagnostic);
2367    }
2368}
2369
2370/// Rolls back registrations in reverse order, ignoring rollback failures.
2371///
2372/// This is used internally during failed initialization and by
2373/// [`clear_plugin_configuration`].
2374pub fn rollback_registrations(registrations: &mut Vec<PluginRegistration>) {
2375    let _ = rollback_registrations_checked(registrations);
2376}
2377
2378struct PluginRollbackOutcome {
2379    errors: Vec<String>,
2380    callbacks_cleared: bool,
2381}
2382
2383impl Default for PluginRollbackOutcome {
2384    fn default() -> Self {
2385        Self {
2386            errors: Vec::new(),
2387            callbacks_cleared: true,
2388        }
2389    }
2390}
2391
2392fn rollback_registrations_checked(
2393    registrations: &mut Vec<PluginRegistration>,
2394) -> PluginRollbackOutcome {
2395    let mut outcome = PluginRollbackOutcome::default();
2396    for registration in registrations.iter_mut().rev() {
2397        match catch_unwind(AssertUnwindSafe(|| (registration.deregister)())) {
2398            Ok(PluginRegistrationCleanupOutcome::Removed) => {}
2399            Ok(PluginRegistrationCleanupOutcome::RemovedWithError(error)) => {
2400                outcome.errors.push(format!(
2401                    "{} registration '{}' reported a delivery failure: {error}",
2402                    registration.kind, registration.name
2403                ));
2404            }
2405            Ok(PluginRegistrationCleanupOutcome::NotRemoved(error)) => {
2406                outcome.callbacks_cleared = false;
2407                outcome.errors.push(format!(
2408                    "{} registration '{}' could not be removed: {error}",
2409                    registration.kind, registration.name
2410                ));
2411            }
2412            Err(payload) => {
2413                outcome.callbacks_cleared = false;
2414                outcome.errors.push(format!(
2415                    "{} registration '{}' could not be removed: deregistration panicked: {}",
2416                    registration.kind,
2417                    registration.name,
2418                    panic_payload_message(payload)
2419                ));
2420            }
2421        }
2422    }
2423    registrations.clear();
2424    outcome
2425}
2426
2427fn panic_payload_message(payload: Box<dyn std::any::Any + Send>) -> String {
2428    payload
2429        .downcast_ref::<&str>()
2430        .map(|message| (*message).to_string())
2431        .or_else(|| payload.downcast_ref::<String>().cloned())
2432        .unwrap_or_else(|| "unknown panic payload".into())
2433}
2434
2435struct ActivePluginConfiguration {
2436    config: PluginConfig,
2437    report: ConfigReport,
2438    registrations: Vec<PluginRegistration>,
2439}
2440
2441async fn initialize_plugin_components(
2442    config: &PluginConfig,
2443    rollback_failures: Option<Arc<Mutex<Vec<String>>>>,
2444) -> Result<Vec<PluginRegistration>> {
2445    ensure_builtin_plugins_registered()?;
2446    let totals = plugin_component_totals(config);
2447    let mut ordinals: HashMap<&str, usize> = HashMap::new();
2448    let mut registrations = PendingPluginRegistrations::new(rollback_failures.clone());
2449
2450    for component in config
2451        .components
2452        .iter()
2453        .filter(|component| component.enabled)
2454    {
2455        let Some(plugin) = lookup_registered_plugin(&component.kind) else {
2456            return Err(PluginError::NotFound(format!(
2457                "plugin component '{}' is not registered",
2458                component.kind
2459            )));
2460        };
2461
2462        let ordinal = ordinals
2463            .entry(component.kind.as_str())
2464            .and_modify(|value| *value += 1)
2465            .or_insert(1);
2466        let namespace = component_namespace(
2467            &component.kind,
2468            *ordinal,
2469            totals.get(component.kind.as_str()).copied().unwrap_or(1),
2470        );
2471
2472        let mut pending =
2473            PendingPluginRegistrationContext::new(namespace, rollback_failures.clone());
2474        plugin
2475            .register(&component.config, &mut pending.context)
2476            .await?;
2477        registrations.extend(pending.take());
2478    }
2479
2480    Ok(registrations.take())
2481}
2482
2483struct PendingPluginRegistrations {
2484    registrations: Vec<PluginRegistration>,
2485    rollback_failures: Option<Arc<Mutex<Vec<String>>>>,
2486}
2487
2488impl PendingPluginRegistrations {
2489    fn new(rollback_failures: Option<Arc<Mutex<Vec<String>>>>) -> Self {
2490        Self {
2491            registrations: Vec::new(),
2492            rollback_failures,
2493        }
2494    }
2495
2496    fn extend(&mut self, registrations: Vec<PluginRegistration>) {
2497        self.registrations.extend(registrations);
2498    }
2499
2500    fn take(&mut self) -> Vec<PluginRegistration> {
2501        std::mem::take(&mut self.registrations)
2502    }
2503}
2504
2505impl Drop for PendingPluginRegistrations {
2506    fn drop(&mut self) {
2507        let outcome = rollback_registrations_checked(&mut self.registrations);
2508        if !outcome.callbacks_cleared {
2509            record_rollback_failures(self.rollback_failures.as_ref(), outcome.errors);
2510        }
2511    }
2512}
2513
2514struct PendingPluginRegistrationContext {
2515    context: PluginRegistrationContext,
2516    rollback_failures: Option<Arc<Mutex<Vec<String>>>>,
2517}
2518
2519impl PendingPluginRegistrationContext {
2520    fn new(namespace: String, rollback_failures: Option<Arc<Mutex<Vec<String>>>>) -> Self {
2521        Self {
2522            context: PluginRegistrationContext::with_namespace(namespace),
2523            rollback_failures,
2524        }
2525    }
2526
2527    fn take(&mut self) -> Vec<PluginRegistration> {
2528        std::mem::take(&mut self.context.registrations)
2529    }
2530}
2531
2532impl Drop for PendingPluginRegistrationContext {
2533    fn drop(&mut self) {
2534        let outcome = rollback_registrations_checked(&mut self.context.registrations);
2535        if !outcome.callbacks_cleared {
2536            record_rollback_failures(self.rollback_failures.as_ref(), outcome.errors);
2537        }
2538    }
2539}
2540
2541fn record_rollback_failures(
2542    rollback_failures: Option<&Arc<Mutex<Vec<String>>>>,
2543    errors: Vec<String>,
2544) {
2545    if errors.is_empty() {
2546        return;
2547    }
2548    if let Some(rollback_failures) = rollback_failures
2549        && let Ok(mut recorded) = rollback_failures.lock()
2550    {
2551        recorded.extend(errors);
2552    }
2553}
2554
2555fn store_active_plugin_configuration(
2556    config: PluginConfig,
2557    report: ConfigReport,
2558    registrations: Vec<PluginRegistration>,
2559) -> Result<()> {
2560    let mut guard = ACTIVE_PLUGIN_CONFIGURATION.lock().map_err(|err| {
2561        PluginError::Internal(format!("active plugin configuration lock poisoned: {err}"))
2562    })?;
2563    *guard = Some(ActivePluginConfiguration {
2564        config,
2565        report,
2566        registrations,
2567    });
2568    if let Ok(mut guard) = LAST_FAILED_RUNTIME_DIAGNOSTICS_REPORT.lock() {
2569        *guard = None;
2570    }
2571    Ok(())
2572}
2573
2574fn plugin_component_totals(config: &PluginConfig) -> HashMap<&str, usize> {
2575    let mut totals = HashMap::new();
2576    for component in &config.components {
2577        *totals.entry(component.kind.as_str()).or_insert(0) += 1;
2578    }
2579    totals
2580}
2581
2582fn component_namespace(kind: &str, ordinal: usize, total: usize) -> String {
2583    if total > 1 {
2584        format!("__nemo_relay_plugin__{kind}__{ordinal}__")
2585    } else {
2586        format!("__nemo_relay_plugin__{kind}__")
2587    }
2588}
2589
2590fn validate_plugin_multiplicity(report: &mut ConfigReport, config: &PluginConfig) {
2591    let totals = plugin_component_totals(config);
2592    let mut emitted = HashSet::new();
2593
2594    for component in &config.components {
2595        let count = totals
2596            .get(component.kind.as_str())
2597            .copied()
2598            .unwrap_or_default();
2599        if count <= 1 || !emitted.insert(component.kind.clone()) {
2600            continue;
2601        }
2602
2603        let allows_multiple = lookup_registered_plugin(&component.kind)
2604            .map(|plugin| plugin.allows_multiple_components())
2605            .unwrap_or(true);
2606        if !allows_multiple {
2607            report.diagnostics.push(ConfigDiagnostic {
2608                level: DiagnosticLevel::Error,
2609                code: "plugin.duplicate_component".to_string(),
2610                component: Some(component.kind.clone()),
2611                field: None,
2612                message: format!(
2613                    "plugin component kind '{}' may only appear once",
2614                    component.kind
2615                ),
2616            });
2617        }
2618    }
2619}
2620
2621fn push_policy_diag(
2622    diagnostics: &mut Vec<ConfigDiagnostic>,
2623    behavior: UnsupportedBehavior,
2624    code: &str,
2625    component: Option<String>,
2626    field: Option<String>,
2627    message: String,
2628) {
2629    let level = match behavior {
2630        UnsupportedBehavior::Ignore => return,
2631        UnsupportedBehavior::Warn => DiagnosticLevel::Warning,
2632        UnsupportedBehavior::Error => DiagnosticLevel::Error,
2633    };
2634
2635    diagnostics.push(ConfigDiagnostic {
2636        level,
2637        code: code.to_string(),
2638        component,
2639        field,
2640        message,
2641    });
2642}
2643
2644fn join_error_messages(report: &ConfigReport) -> String {
2645    report
2646        .diagnostics
2647        .iter()
2648        .filter(|diag| diag.level == DiagnosticLevel::Error)
2649        .map(|diag| diag.message.as_str())
2650        .collect::<Vec<_>>()
2651        .join("; ")
2652}
2653
2654#[cfg(test)]
2655#[path = "../tests/unit/plugin_tests.rs"]
2656mod tests;