Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 184 additions & 12 deletions crates/cli/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use std::collections::HashSet;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::path::{Path, PathBuf};

use axum::http::HeaderMap;
use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
use nemo_relay::plugin::{PluginError, load_plugin_config_files};
use serde::Deserialize;
use serde_json::Value;
use nemo_relay::plugin::dynamic::DynamicPluginManifest;
use nemo_relay::plugin::{PluginError, merge_plugin_config_documents};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

use crate::error::CliError;
use crate::plugin_shim::PluginShimCommand;
Expand Down Expand Up @@ -478,6 +480,32 @@ impl GatewayConfig {
pub(crate) struct ResolvedConfig {
pub(crate) gateway: GatewayConfig,
pub(crate) agents: AgentConfigs,
pub(crate) dynamic_plugins: Vec<ResolvedDynamicPluginConfig>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedDynamicPluginConfig {
pub(crate) plugin_id: String,
pub(crate) manifest_ref: String,
pub(crate) config: Map<String, Value>,
pub(crate) source: PathBuf,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum DynamicPluginHostConfigStatus {
Absent,
Present,
}

impl ResolvedDynamicPluginConfig {
pub(crate) fn host_config_status(&self) -> DynamicPluginHostConfigStatus {
if self.config.is_empty() {
DynamicPluginHostConfigStatus::Absent
} else {
DynamicPluginHostConfigStatus::Present
}
}
}

#[derive(Debug, Clone, Default)]
Expand Down Expand Up @@ -734,7 +762,7 @@ fn load_shared_config(explicit: Option<&PathBuf>) -> Result<ResolvedConfig, CliE
};
apply_file_config(&mut resolved, merged)?;
apply_plugin_toml_config(
&mut resolved.gateway,
&mut resolved,
config_toml_plugin_sources.first(),
plugin_toml,
)?;
Expand Down Expand Up @@ -893,10 +921,25 @@ fn apply_file_plugins_config(gateway: &mut GatewayConfig, plugins: Option<FilePl

#[derive(Debug, Clone)]
struct PluginTomlConfig {
value: Value,
value: Option<Value>,
dynamic_plugins: Vec<ResolvedDynamicPluginConfig>,
sources: Vec<PathBuf>,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct PluginTomlPluginsSection {
#[serde(default)]
dynamic: Vec<FileDynamicPluginConfig>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileDynamicPluginConfig {
manifest: String,
#[serde(default)]
config: Map<String, Value>,
}

fn load_plugin_toml_config(
explicit: Option<&PathBuf>,
) -> Result<Option<PluginTomlConfig>, CliError> {
Expand All @@ -907,33 +950,162 @@ fn load_plugin_toml_config_from_paths<I>(paths: I) -> Result<Option<PluginTomlCo
where
I: IntoIterator<Item = PathBuf>,
{
// Delegate read/parse/merge to the shared core primitive (file precedence unchanged).
let resolved = load_plugin_config_files(paths).map_err(|err| match err {
let paths = paths.into_iter().collect::<Vec<_>>();
let mut dynamic_plugins = Vec::new();
let mut seen_plugin_ids = HashSet::new();
let mut runtime_documents = Vec::new();

for path in &paths {
if !path.exists() {
continue;
}
let raw = std::fs::read_to_string(path)?;
let mut parsed = raw
.parse::<toml::Table>()
.map(toml::Value::Table)
.map_err(|error| {
CliError::Config(format!(
"invalid plugin TOML in {}: {error}",
path.display()
))
})?;
dynamic_plugins.extend(resolve_dynamic_plugin_refs(
path,
&mut parsed,
&mut seen_plugin_ids,
)?);
runtime_documents.push((
path.clone(),
serde_json::to_value(remove_dynamic_plugin_section(parsed))
.expect("toml value serializes to JSON"),
));
}

// Delegate merged runtime plugin config to the shared core primitive after dynamic refs have
// been validated independently. File precedence stays unchanged for the generic runtime path.
let resolved = merge_plugin_config_documents(runtime_documents).map_err(|err| match err {
PluginError::InvalidConfig(message) => CliError::Config(message),
other => CliError::Config(other.to_string()),
})?;
Ok(resolved.map(|(value, sources)| PluginTomlConfig { value, sources }))
match resolved {
Some((value, sources)) => Ok(Some(PluginTomlConfig {
value: plugin_toml_runtime_value(value),
dynamic_plugins,
sources,
})),
None => Ok((!dynamic_plugins.is_empty()).then_some(PluginTomlConfig {
value: None,
dynamic_plugins,
sources: Vec::new(),
})),
}
}

fn apply_plugin_toml_config(
gateway: &mut GatewayConfig,
resolved: &mut ResolvedConfig,
config_toml_plugin_source: Option<&PathBuf>,
plugin_toml: Option<PluginTomlConfig>,
) -> Result<(), CliError> {
let Some(plugin_toml) = plugin_toml else {
return Ok(());
};
if let Some(config_source) = config_toml_plugin_source {
if let Some(config_source) = config_toml_plugin_source
&& plugin_toml.value.is_some()
{
return Err(CliError::Config(format!(
"plugin config is defined in both {} and {}; choose one source",
config_source.display(),
format_paths(&plugin_toml.sources)
)));
}
gateway.plugin_config = Some(plugin_toml.value);
if let Some(value) = plugin_toml.value {
resolved.gateway.plugin_config = Some(value);
}
resolved.dynamic_plugins = plugin_toml.dynamic_plugins;
Comment thread
afourniernv marked this conversation as resolved.
Ok(())
}

fn resolve_dynamic_plugin_refs(
source: &Path,
value: &mut toml::Value,
seen_plugin_ids: &mut HashSet<String>,
) -> Result<Vec<ResolvedDynamicPluginConfig>, CliError> {
let Some(root) = value.as_table_mut() else {
return Ok(Vec::new());
};

let plugins_value = root.get("plugins").cloned();
let Some(plugins_value) = plugins_value else {
return Ok(Vec::new());
};

let plugins: PluginTomlPluginsSection = plugins_value.try_into().map_err(|error| {
CliError::Config(format!(
"invalid dynamic plugin config in {}: {error}",
source.display()
))
})?;

if let Some(toml::Value::Table(plugins_table)) = root.get_mut("plugins") {
plugins_table.remove("dynamic");
if plugins_table.is_empty() {
root.remove("plugins");
}
}

let mut resolved = Vec::with_capacity(plugins.dynamic.len());
for dynamic in plugins.dynamic {
let manifest_path = resolve_dynamic_manifest_path(source, &dynamic.manifest);
let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path)
.map_err(|error| CliError::Config(error.to_string()))?;
let plugin_id = manifest.plugin.id.trim().to_owned();
if !seen_plugin_ids.insert(plugin_id.clone()) {
return Err(CliError::Config(format!(
"duplicate dynamic plugin id '{}' across plugins.toml sources",
plugin_id
)));
}
resolved.push(ResolvedDynamicPluginConfig {
plugin_id,
manifest_ref,
config: dynamic.config,
source: source.to_path_buf(),
});
}
Ok(resolved)
}

fn resolve_dynamic_manifest_path(source: &Path, manifest: &str) -> PathBuf {
let manifest = PathBuf::from(manifest);
if manifest.is_absolute() {
manifest
} else {
source
.parent()
.map(|parent| parent.join(&manifest))
.unwrap_or(manifest)
}
}

fn plugin_toml_runtime_value(value: Value) -> Option<Value> {
match value {
Value::Object(ref object) if object.is_empty() => None,
other => Some(other),
}
}

fn remove_dynamic_plugin_section(mut value: toml::Value) -> toml::Value {
if let Some(root) = value.as_table_mut()
&& let Some(toml::Value::Table(plugins)) = root.get_mut("plugins")
{
plugins.remove("dynamic");
if plugins.is_empty() {
root.remove("plugins");
}
}
value
}

fn apply_cli_plugin_config(config: &mut GatewayConfig, value: &str) -> Result<(), CliError> {
if config.plugin_config.is_some() {
return Err(CliError::Config(
Expand Down
80 changes: 79 additions & 1 deletion crates/cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use uuid::Uuid;

use crate::config::{
AgentConfigs, CodingAgent, GatewayConfig, ResolvedConfig, ServerArgs, resolve_server_config,
AgentConfigs, CodingAgent, DynamicPluginHostConfigStatus, GatewayConfig, ResolvedConfig,
ServerArgs, resolve_server_config,
};
use crate::error::CliError;

Expand Down Expand Up @@ -84,6 +85,15 @@ pub(crate) struct ConfigurationInfo {
pub resolution: Check,
pub default_agent: Option<String>,
pub configured_agents: Vec<String>,
pub dynamic_plugins: Vec<DynamicPluginReferenceInfo>,
}

#[derive(Debug, Clone, Serialize)]
pub(crate) struct DynamicPluginReferenceInfo {
pub plugin_id: String,
pub manifest_ref: String,
pub source: PathBuf,
pub host_config_status: DynamicPluginHostConfigStatus,
}

#[derive(Debug, Clone, Serialize)]
Expand Down Expand Up @@ -144,6 +154,7 @@ pub(crate) async fn collect_report(
home.as_deref(),
resolution,
configured_agents,
&resolved.dynamic_plugins,
),
agents: collect_agents(target_agent, &resolved).await,
observability: collect_observability(&resolved.gateway).await,
Expand Down Expand Up @@ -179,6 +190,7 @@ fn collect_configuration(
home: Option<&Path>,
resolution: Check,
configured_agents: Vec<String>,
dynamic_plugins: &[crate::config::ResolvedDynamicPluginConfig],
) -> ConfigurationInfo {
let workspace_path = cwd
.map(|p| p.join(".nemo-relay").join("config.toml"))
Expand All @@ -200,6 +212,43 @@ fn collect_configuration(
// out of FileConfig. Doctor reports `None` until that lands.
default_agent: None,
configured_agents,
dynamic_plugins: dynamic_plugins
.iter()
.map(|plugin| DynamicPluginReferenceInfo {
plugin_id: plugin.plugin_id.clone(),
manifest_ref: plugin.manifest_ref.clone(),
source: plugin.source.clone(),
host_config_status: plugin.host_config_status(),
})
.collect(),
}
}

fn dynamic_plugin_reference_check(plugin: &DynamicPluginReferenceInfo) -> Check {
Check {
name: "Dynamic plugin",
status: Status::Pass,
details: format!("{} resolved from {}", plugin.plugin_id, plugin.manifest_ref),
}
}

fn dynamic_plugin_host_config_check(plugin: &DynamicPluginReferenceInfo) -> Check {
let details = match plugin.host_config_status {
DynamicPluginHostConfigStatus::Absent => {
format!(
"{} discovered via host config only; not enabled by config alone",
plugin.plugin_id
)
}
DynamicPluginHostConfigStatus::Present => format!(
"{} discovered via host config; host-owned config present; not enabled by config alone",
plugin.plugin_id
),
};
Check {
name: "Dynamic plugin",
status: Status::Info,
details,
}
}

Expand Down Expand Up @@ -1346,6 +1395,35 @@ pub(crate) fn format_human(report: &DoctorReport) -> String {
report.configuration.configured_agents.join(", ")
));
}
if !report.configuration.dynamic_plugins.is_empty() {
for (index, plugin) in report.configuration.dynamic_plugins.iter().enumerate() {
let label = if index == 0 { "Dynamic" } else { " " };
let config_suffix = if matches!(
plugin.host_config_status,
DynamicPluginHostConfigStatus::Present
) {
"; host config"
} else {
""
};
out.push_str(&format!(
" {label:<10}{} ({}){}\n",
plugin.plugin_id, plugin.manifest_ref, config_suffix
));
}
}
for plugin in &report.configuration.dynamic_plugins {
for check in [
dynamic_plugin_reference_check(plugin),
dynamic_plugin_host_config_check(plugin),
] {
out.push_str(&format!(
" Dynamic {} {}\n",
format_status(check.status),
check.details
));
}
}
out.push('\n');

out.push_str(" Agents detected\n");
Expand Down
Loading
Loading