From 60b059c8ae704fe957ec50b164b10a28b756ea66 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 22 Jun 2026 21:57:49 -0700 Subject: [PATCH 1/3] feat: support dynamic plugin discovery via plugins.toml Signed-off-by: Alex Fournier --- crates/cli/src/config.rs | 196 +++++++++++++++++-- crates/cli/src/doctor.rs | 80 +++++++- crates/cli/tests/coverage/config_tests.rs | 197 +++++++++++++++++++- crates/cli/tests/coverage/doctor_tests.rs | 54 ++++++ crates/cli/tests/coverage/launcher_tests.rs | 14 ++ crates/core/src/plugin.rs | 20 +- 6 files changed, 540 insertions(+), 21 deletions(-) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index f52495e4..9c4d4c7c 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -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; @@ -478,6 +480,32 @@ impl GatewayConfig { pub(crate) struct ResolvedConfig { pub(crate) gateway: GatewayConfig, pub(crate) agents: AgentConfigs, + pub(crate) dynamic_plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedDynamicPluginConfig { + pub(crate) plugin_id: String, + pub(crate) manifest_ref: String, + pub(crate) config: Map, + 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)] @@ -734,7 +762,7 @@ fn load_shared_config(explicit: Option<&PathBuf>) -> Result, + dynamic_plugins: Vec, sources: Vec, } +#[derive(Debug, Clone, Default, Deserialize)] +struct PluginTomlPluginsSection { + #[serde(default)] + dynamic: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileDynamicPluginConfig { + manifest: String, + #[serde(default)] + config: Map, +} + fn load_plugin_toml_config( explicit: Option<&PathBuf>, ) -> Result, CliError> { @@ -907,33 +950,162 @@ fn load_plugin_toml_config_from_paths(paths: I) -> Result, { - // 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::>(); + 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::() + .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, ) -> 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; Ok(()) } +fn resolve_dynamic_plugin_refs( + source: &Path, + value: &mut toml::Value, + seen_plugin_ids: &mut HashSet, +) -> Result, 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 { + 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( diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index 8cf4ffca..5b1f1c32 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -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; @@ -84,6 +85,15 @@ pub(crate) struct ConfigurationInfo { pub resolution: Check, pub default_agent: Option, pub configured_agents: Vec, + pub dynamic_plugins: Vec, +} + +#[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)] @@ -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, @@ -179,6 +190,7 @@ fn collect_configuration( home: Option<&Path>, resolution: Check, configured_agents: Vec, + dynamic_plugins: &[crate::config::ResolvedDynamicPluginConfig], ) -> ConfigurationInfo { let workspace_path = cwd .map(|p| p.join(".nemo-relay").join("config.toml")) @@ -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, } } @@ -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"); diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index be80bb60..1d4faf66 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -22,6 +22,38 @@ fn isolated_config_path(temp: &tempfile::TempDir) -> std::path::PathBuf { temp.path().join("config.toml") } +fn write_dynamic_manifest(dir: &std::path::Path, plugin_id: &str) -> std::path::PathBuf { + let manifest_path = dir.join("relay-plugin.toml"); + std::fs::write( + &manifest_path, + format!( + r#" +manifest_version = 1 + +[plugin] +id = "{plugin_id}" +kind = "worker" + +[compat] +relay = "0.1" +worker_protocol = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[load] +runtime = "python" +entrypoint = "{plugin_id}.plugin:register" +"# + ), + ) + .unwrap(); + manifest_path +} + #[test] fn session_config_prefers_headers_and_parses_json() { let mut headers = HeaderMap::new(); @@ -374,7 +406,7 @@ source = "user" assert_eq!( resolved.map(|config| config.value), - Some(json!({ + Some(Some(json!({ "version": 1, "components": [ { @@ -407,7 +439,7 @@ source = "user" } } ] - })) + }))) ); } @@ -451,7 +483,7 @@ path = "/home/user/.config/nemo-relay/pricing.json" assert_eq!( resolved.map(|config| config.value), - Some(json!({ + Some(Some(json!({ "version": 1, "components": [ { @@ -471,7 +503,7 @@ path = "/home/user/.config/nemo-relay/pricing.json" } } ] - })) + }))) ); } @@ -522,7 +554,7 @@ mode = "append" assert_eq!( resolved.map(|config| config.value), - Some(json!({ + Some(Some(json!({ "version": 1, "components": [ { @@ -538,8 +570,125 @@ mode = "append" } } ] + }))) + ); +} + +#[test] +fn plugins_toml_resolves_dynamic_plugin_refs_without_polluting_runtime_plugin_config() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.worker"); + let plugins_path = temp.path().join("plugins.toml"); + std::fs::write( + &plugins_path, + r#" +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" + +[plugins.dynamic.config] +mode = "strict" +"#, + ) + .unwrap(); + + let resolved = load_plugin_toml_config_from_paths(vec![plugins_path]) + .unwrap() + .unwrap(); + + assert_eq!( + resolved.value, + Some(json!({ + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": true, + "config": { + "version": 1 + } + } + ] })) ); + assert_eq!(resolved.dynamic_plugins.len(), 1); + assert_eq!(resolved.dynamic_plugins[0].plugin_id, "acme.worker"); + assert_eq!( + resolved.dynamic_plugins[0].manifest_ref, + manifest_path.canonicalize().unwrap().to_string_lossy() + ); + assert_eq!( + resolved.dynamic_plugins[0].config, + serde_json::Map::from_iter([("mode".into(), json!("strict"))]) + ); +} + +#[test] +fn plugins_toml_rejects_duplicate_dynamic_plugin_ids_across_sources() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, "acme.worker"); + let project_plugin = temp.path().join("project-plugins.toml"); + let user_plugin = temp.path().join("user-plugins.toml"); + std::fs::write( + &project_plugin, + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" +"#, + ) + .unwrap(); + std::fs::write( + &user_plugin, + r#" +[[plugins.dynamic]] +manifest = "plugins/acme" +"#, + ) + .unwrap(); + + let error = load_plugin_toml_config_from_paths(vec![project_plugin, user_plugin]) + .unwrap_err() + .to_string(); + + assert!(error.contains("duplicate dynamic plugin id")); + assert!(error.contains("acme.worker")); +} + +#[test] +fn plugins_toml_rejects_dynamic_plugin_lifecycle_fields() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, "acme.worker"); + let plugins_path = temp.path().join("plugins.toml"); + std::fs::write( + &plugins_path, + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" +enabled = true +"#, + ) + .unwrap(); + + let error = load_plugin_toml_config_from_paths(vec![plugins_path]) + .unwrap_err() + .to_string(); + + assert!(error.contains("invalid dynamic plugin config")); + assert!(error.contains("enabled")); } #[test] @@ -593,6 +742,44 @@ config = { version = 1, components = [] } assert!(error.contains("plugins.toml")); } +#[test] +fn plugins_toml_with_only_dynamic_plugins_preserves_config_toml_plugin_config() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, "acme.worker"); + let config_path = temp.path().join("config.toml"); + std::fs::write( + &config_path, + r#" +[plugins] +config = { version = 1, components = [] } +"#, + ) + .unwrap(); + std::fs::write( + temp.path().join("plugins.toml"), + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" +"#, + ) + .unwrap(); + let args = ServerArgs { + config: Some(config_path), + ..ServerArgs::default() + }; + + let resolved = resolve_server_config(&args).unwrap(); + + assert_eq!( + resolved.gateway.plugin_config, + Some(json!({ "version": 1, "components": [] })) + ); + assert_eq!(resolved.dynamic_plugins.len(), 1); + assert_eq!(resolved.dynamic_plugins[0].plugin_id, "acme.worker"); +} + #[test] fn cli_plugin_config_conflicts_with_file_plugin_config() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 1df6e84a..064dfce0 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -120,6 +120,7 @@ fn empty_report() -> DoctorReport { }, default_agent: None, configured_agents: vec![], + dynamic_plugins: vec![], }, agents: vec![], observability: vec![], @@ -301,6 +302,30 @@ fn format_json_is_stable_and_versioned() { assert!(parsed["target_agent"].is_null()); assert!(parsed["environment"]["os"].is_string()); assert!(parsed["agents"].is_array()); + assert!(parsed["configuration"]["dynamic_plugins"].is_array()); +} + +#[test] +fn format_json_reports_discovered_dynamic_plugin_fields() { + let mut report = empty_report(); + report.configuration.dynamic_plugins = vec![DynamicPluginReferenceInfo { + plugin_id: "acme.worker".into(), + manifest_ref: "/tmp/plugins/acme/relay-plugin.toml".into(), + source: PathBuf::from("/tmp/plugins.toml"), + host_config_status: DynamicPluginHostConfigStatus::Present, + }]; + + let json = format_json(&report).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + let plugin = &parsed["configuration"]["dynamic_plugins"][0]; + + assert_eq!(plugin["plugin_id"], "acme.worker"); + assert_eq!( + plugin["manifest_ref"], + "/tmp/plugins/acme/relay-plugin.toml" + ); + assert_eq!(plugin["source"], "/tmp/plugins.toml"); + assert_eq!(plugin["host_config_status"], "present"); } #[test] @@ -375,6 +400,7 @@ fn collect_configuration_uses_xdg_global_path_and_renders_resolution_branches() details: "using fallback layer".into(), }, vec!["codex".into(), "hermes".into()], + &[], ); assert_eq!(configuration.workspace.status, Status::Pass); @@ -559,6 +585,7 @@ fn configuration_and_path_helpers_cover_direct_paths_and_fallbacks() { details: "valid".into(), }, vec!["codex".into()], + &[], ); assert_eq!(info.workspace.status, Status::Pass); assert!(info.global.path.starts_with(&home)); @@ -574,6 +601,33 @@ fn configuration_and_path_helpers_cover_direct_paths_and_fallbacks() { ); } +#[test] +fn format_human_reports_discovered_dynamic_plugins_in_configuration() { + let mut report = empty_report(); + report.configuration.dynamic_plugins = vec![ + DynamicPluginReferenceInfo { + plugin_id: "acme.worker".into(), + manifest_ref: "/tmp/plugins/acme/relay-plugin.toml".into(), + source: PathBuf::from("/tmp/plugins.toml"), + host_config_status: DynamicPluginHostConfigStatus::Present, + }, + DynamicPluginReferenceInfo { + plugin_id: "acme.native".into(), + manifest_ref: "/tmp/plugins/native/relay-plugin.toml".into(), + source: PathBuf::from("/tmp/plugins.toml"), + host_config_status: DynamicPluginHostConfigStatus::Absent, + }, + ]; + + let rendered = format_human(&report); + + assert!(rendered.contains("Dynamic")); + assert!(rendered.contains("acme.worker (/tmp/plugins/acme/relay-plugin.toml); host config")); + assert!(rendered.contains("acme.native (/tmp/plugins/native/relay-plugin.toml)")); + assert!(rendered.contains("acme.worker resolved from /tmp/plugins/acme/relay-plugin.toml")); + assert!(rendered.contains("not enabled by config alone")); +} + #[test] fn observability_component_helpers_cover_disabled_and_default_paths() { let plugin = serde_json::json!({ diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index cbc8c78f..f524643b 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -245,6 +245,7 @@ fn prepares_codex_config_overrides() { let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( CodingAgent::Codex, @@ -321,6 +322,7 @@ fn prepares_codex_with_hooks_when_auth_missing() { let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( @@ -467,6 +469,7 @@ fn prepares_claude_dry_run_without_writing_plugin() { let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( CodingAgent::ClaudeCode, @@ -492,6 +495,7 @@ fn prepares_claude_dry_inserts_plugin_dir_after_last_agent_executable() { let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( CodingAgent::ClaudeCode, @@ -537,6 +541,7 @@ fn cursor_patching_can_be_disabled() { }, ..AgentConfigs::default() }, + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( @@ -569,6 +574,7 @@ fn prepares_hermes_hook_environment() { }, ..AgentConfigs::default() }, + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( CodingAgent::Hermes, @@ -617,6 +623,7 @@ fn prepares_hermes_dry_uses_home_path_without_writing_hooks() { let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( @@ -692,6 +699,7 @@ fn hermes_patch_restore_restores_original_file() { }, ..AgentConfigs::default() }, + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( @@ -718,6 +726,7 @@ fn prepares_claude_temp_plugin() { let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( CodingAgent::ClaudeCode, @@ -760,6 +769,7 @@ fn cursor_patch_restore_restores_original_file() { }, ..AgentConfigs::default() }, + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( @@ -802,6 +812,7 @@ fn cursor_patch_restore_uses_nearest_project_cursor_dir() { let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( @@ -837,6 +848,7 @@ fn cursor_patch_restore_removes_temporary_file() { let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( @@ -1031,6 +1043,7 @@ fn cursor_dry_run_does_not_write_hooks() { let resolved = ResolvedConfig { gateway: GatewayConfig::default(), agents: AgentConfigs::default(), + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( @@ -1149,6 +1162,7 @@ async fn execute_live_run_restores_hermes_hooks_when_health_check_fails() { }, ..AgentConfigs::default() }, + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( CodingAgent::Hermes, diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index cf406b56..6ddbd819 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -1121,8 +1121,7 @@ pub fn load_plugin_config_files(paths: I) -> Result, { - let mut merged = Json::Object(Map::new()); - let mut sources = Vec::new(); + let mut documents = Vec::new(); for path in paths { if !path.exists() { continue; @@ -1133,7 +1132,22 @@ where let parsed = raw.parse::().map_err(|err| { PluginError::InvalidConfig(format!("invalid plugin TOML in {}: {err}", path.display())) })?; - let document = serde_json::to_value(parsed)?; + documents.push((path, serde_json::to_value(parsed)?)); + } + merge_plugin_config_documents(documents) +} + +/// Merges pre-parsed `plugins.toml` JSON documents (lowest precedence first) using the canonical +/// plugin-config layering rules. Internal: `pub` only so the CLI can preprocess dynamic-plugin +/// refs while still sharing one merge semantics implementation with core. +#[doc(hidden)] +pub fn merge_plugin_config_documents(documents: I) -> Result)>> +where + I: IntoIterator, +{ + let mut merged = Json::Object(Map::new()); + let mut sources = Vec::new(); + for (path, document) in documents { validate_unique_component_kinds(&path, &document)?; layer_config(&mut merged, document); sources.push(path); From 914040199eb1db262f2277f19514e6503015c2d2 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 23 Jun 2026 07:51:40 -0700 Subject: [PATCH 2/3] test: harden dynamic plugin discovery config coverage Signed-off-by: Alex Fournier --- crates/cli/tests/coverage/config_tests.rs | 115 ++++++++++++++++++++++ crates/cli/tests/coverage/doctor_tests.rs | 4 + 2 files changed, 119 insertions(+) diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index 1d4faf66..8770c846 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -633,6 +633,37 @@ mode = "strict" ); } +#[test] +fn plugins_toml_resolves_dynamic_plugin_refs_from_absolute_manifest_paths() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.worker"); + let plugins_path = temp.path().join("plugins.toml"); + std::fs::write( + &plugins_path, + format!( + r#" +[[plugins.dynamic]] +manifest = "{}" +"#, + manifest_path.display() + ), + ) + .unwrap(); + + let resolved = load_plugin_toml_config_from_paths(vec![plugins_path]) + .unwrap() + .unwrap(); + + assert_eq!(resolved.dynamic_plugins.len(), 1); + assert_eq!(resolved.dynamic_plugins[0].plugin_id, "acme.worker"); + assert_eq!( + resolved.dynamic_plugins[0].manifest_ref, + manifest_path.canonicalize().unwrap().to_string_lossy() + ); +} + #[test] fn plugins_toml_rejects_duplicate_dynamic_plugin_ids_across_sources() { let temp = tempfile::tempdir().unwrap(); @@ -666,6 +697,36 @@ manifest = "plugins/acme" assert!(error.contains("acme.worker")); } +#[test] +fn plugins_toml_rejects_duplicate_dynamic_plugin_ids_within_one_file() { + let temp = tempfile::tempdir().unwrap(); + let plugin_a = temp.path().join("plugins/a"); + let plugin_b = temp.path().join("plugins/b"); + std::fs::create_dir_all(&plugin_a).unwrap(); + std::fs::create_dir_all(&plugin_b).unwrap(); + write_dynamic_manifest(&plugin_a, "acme.worker"); + write_dynamic_manifest(&plugin_b, "acme.worker"); + let plugins_path = temp.path().join("plugins.toml"); + std::fs::write( + &plugins_path, + r#" +[[plugins.dynamic]] +manifest = "plugins/a/relay-plugin.toml" + +[[plugins.dynamic]] +manifest = "plugins/b/relay-plugin.toml" +"#, + ) + .unwrap(); + + let error = load_plugin_toml_config_from_paths(vec![plugins_path]) + .unwrap_err() + .to_string(); + + assert!(error.contains("duplicate dynamic plugin id")); + assert!(error.contains("acme.worker")); +} + #[test] fn plugins_toml_rejects_dynamic_plugin_lifecycle_fields() { let temp = tempfile::tempdir().unwrap(); @@ -691,6 +752,60 @@ enabled = true assert!(error.contains("enabled")); } +#[test] +fn plugins_toml_layers_runtime_plugin_config_and_dynamic_only_sources_independently() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, "acme.worker"); + let lower_priority = temp.path().join("lower-plugins.toml"); + let higher_priority = temp.path().join("higher-plugins.toml"); + std::fs::write( + &lower_priority, + r#" +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 +"#, + ) + .unwrap(); + std::fs::write( + &higher_priority, + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" +"#, + ) + .unwrap(); + + let resolved = load_plugin_toml_config_from_paths(vec![lower_priority, higher_priority]) + .unwrap() + .unwrap(); + + assert_eq!( + resolved.value, + Some(json!({ + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": true, + "config": { + "version": 1 + } + } + ] + })) + ); + assert_eq!(resolved.dynamic_plugins.len(), 1); + assert_eq!(resolved.dynamic_plugins[0].plugin_id, "acme.worker"); +} + #[test] fn plugins_toml_rejects_duplicate_component_kinds_per_file() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index 064dfce0..06485da9 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -625,6 +625,10 @@ fn format_human_reports_discovered_dynamic_plugins_in_configuration() { assert!(rendered.contains("acme.worker (/tmp/plugins/acme/relay-plugin.toml); host config")); assert!(rendered.contains("acme.native (/tmp/plugins/native/relay-plugin.toml)")); assert!(rendered.contains("acme.worker resolved from /tmp/plugins/acme/relay-plugin.toml")); + assert!( + rendered + .contains("acme.native discovered via host config only; not enabled by config alone") + ); assert!(rendered.contains("not enabled by config alone")); } From 3c29bec59cb8fafa2681a69d5ee9f20f9ab6565c Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 23 Jun 2026 08:11:07 -0700 Subject: [PATCH 3/3] test: make absolute dynamic plugin path coverage portable Signed-off-by: Alex Fournier --- crates/cli/tests/coverage/config_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index 8770c846..3baebadc 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -645,7 +645,7 @@ fn plugins_toml_resolves_dynamic_plugin_refs_from_absolute_manifest_paths() { format!( r#" [[plugins.dynamic]] -manifest = "{}" +manifest = '{}' "#, manifest_path.display() ),