diff --git a/crates/adaptive/README.md b/crates/adaptive/README.md index d79bf6c6..7dd1a1ce 100644 --- a/crates/adaptive/README.md +++ b/crates/adaptive/README.md @@ -26,27 +26,27 @@ framework. ## Why Use It? -- βš™οΈ **Install adaptive behavior through plugins**: Enable adaptive runtime +- **Install adaptive behavior through plugins**: Enable adaptive runtime components through the same configuration path as other NeMo Relay plugins. -- πŸ“ˆ **Learn from observed executions**: Derive runtime hints from scope, tool, +- **Learn from observed executions**: Derive runtime hints from scope, tool, and LLM events without replacing the application framework. -- πŸ’Ύ **Choose local or shared state**: Use in-memory state for local runs or the +- **Choose local or shared state**: Use in-memory state for local runs or the optional Redis backend for shared persistence. -- 🧩 **Keep adaptive behavior reusable**: Package telemetry, hint injection, +- **Keep adaptive behavior reusable**: Package telemetry, hint injection, tool parallelism, and cache-governor behavior behind stable component settings. ## What You Get -- βœ… **`AdaptiveConfig`**: A canonical config contract for the top-level +- **`AdaptiveConfig`**: A canonical config contract for the top-level `adaptive` plugin component. -- βœ… **Built-in component settings**: Typed config helpers for telemetry, +- **Built-in component settings**: Typed config helpers for telemetry, adaptive hints, tool parallelism, and the Adaptive Cache Governor. -- βœ… **State backends**: In-memory state by default and Redis-backed state behind +- **State backends**: In-memory state by default and Redis-backed state behind the `redis-backend` feature. -- βœ… **Learning primitives**: Runtime helpers and learners built on NeMo Relay +- **Learning primitives**: Runtime helpers and learners built on NeMo Relay events. -- βœ… **Adaptive Cache Governor (ACG) module surface**: The canonical +- **Adaptive Cache Governor (ACG) module surface**: The canonical `nemo_relay_adaptive::acg` module for PromptIR, provider plugins, stability analysis, and cache telemetry normalization. diff --git a/crates/cli/README.md b/crates/cli/README.md index 75d0262d..b5436a5d 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -26,27 +26,28 @@ with the installed `nemo-relay` command rather than link against the crate. ## Why Use It? -- 🧭 **Observe existing coding agents**: Run Claude Code, Codex, or Hermes +- **Observe existing coding agents**: Run Claude Code, Codex, or Hermes Agent through a local NeMo Relay gateway without changing the agent itself. -- πŸ› οΈ **Configure hooks interactively**: Use the setup wizard to write project or +- **Configure hooks interactively**: Use the setup wizard to write project or user config and install the hook files needed by supported agents. -- πŸ“‘ **Export local sessions**: Write ATIF trajectory files, ATOF event JSONL +- **Export local sessions**: Write ATIF trajectory files, ATOF event JSONL streams, or OpenInference spans from one shared config model. -- 🩺 **Diagnose the machine**: Check config layers, agent binaries, hook status, - observability outputs, and shell completions with `nemo-relay doctor`. +- **Diagnose setup readiness**: Check config layers, `plugins.toml` discovery, + agent binaries, persistent host-plugin installs, hook status, observability + outputs, and shell completions with `nemo-relay doctor`. ## What You Get -- βœ… **`nemo-relay` binary**: The executable installed by the `nemo-relay-cli` +- **`nemo-relay` binary**: The executable installed by the `nemo-relay-cli` Cargo package. -- βœ… **First-run setup**: Bare `nemo-relay` launches setup when no config exists, +- **First-run setup**: Bare `nemo-relay` launches setup when no config exists, then runs doctor once config is present. -- βœ… **Agent shortcuts**: `nemo-relay claude`, `nemo-relay codex`, and +- **Agent shortcuts**: `nemo-relay claude`, `nemo-relay codex`, and `nemo-relay hermes` start observed agent runs. -- βœ… **Config-driven launch**: `nemo-relay run` resolves config, environment, and +- **Config-driven launch**: `nemo-relay run` resolves config, environment, and CLI overrides for deterministic non-interactive use. -- βœ… **Hook forwarding server**: A local gateway accepts agent hook events and +- **Hook forwarding server**: A local gateway accepts agent hook events and provider-shaped OpenAI or Anthropic requests. ## Installation Options diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 5e48eb55..d4491d6f 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -898,6 +898,11 @@ fn plugin_config_paths( implicit_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()) } +/// Returns the implicit `plugins.toml` discovery paths used by the gateway and doctor. +pub(crate) fn default_plugin_config_paths() -> Vec { + plugin_config_paths(None, None) +} + fn implicit_plugin_config_paths( cwd: Option<&std::path::Path>, user_config_dir: Option, @@ -1001,6 +1006,7 @@ struct PluginTomlConfig { value: Option, dynamic_plugins: Vec, dynamic_plugin_policy: DynamicPluginHostPolicy, + contributing_sources: Vec, } #[derive(Debug, Clone, Default, Deserialize)] @@ -1026,6 +1032,18 @@ fn load_plugin_toml_config( load_plugin_toml_config_from_paths(plugin_config_paths(explicit, plugin_config_path)) } +/// Returns the physical `plugins.toml` files that contribute effective runtime or dynamic +/// plugin configuration under the default discovery rules. +pub(crate) fn effective_plugin_toml_sources() -> Result, CliError> { + let Some(config) = load_plugin_toml_config(None, None)? else { + return Ok(Vec::new()); + }; + let mut sources = config.contributing_sources; + sources.sort(); + sources.dedup(); + Ok(sources) +} + fn load_plugin_toml_config_from_paths(paths: I) -> Result, CliError> where I: IntoIterator, @@ -1034,6 +1052,7 @@ where let mut dynamic_plugins = Vec::new(); let mut dynamic_plugin_policy = DynamicPluginHostPolicy::default(); let mut seen_plugin_ids = HashSet::new(); + let mut contributing_sources = Vec::new(); let mut runtime_documents = Vec::new(); for path in &paths { @@ -1051,6 +1070,11 @@ where })?; let resolved_plugins = resolve_dynamic_plugin_refs(path, &mut parsed, &mut seen_plugin_ids)?; + if !resolved_plugins.dynamic_plugins.is_empty() + || resolved_plugins.dynamic_plugin_policy != DynamicPluginHostPolicy::default() + { + contributing_sources.push(path.clone()); + } dynamic_plugins.extend(resolved_plugins.dynamic_plugins); dynamic_plugin_policy.merge_from(resolved_plugins.dynamic_plugin_policy); runtime_documents.push(( @@ -1067,17 +1091,24 @@ where other => CliError::Config(other.to_string()), })?; match resolved { - Some((value, _sources)) => Ok(Some(PluginTomlConfig { - value: plugin_toml_runtime_value(value), - dynamic_plugins, - dynamic_plugin_policy, - })), + Some((value, sources)) => { + contributing_sources.extend(sources.iter().cloned()); + contributing_sources.sort(); + contributing_sources.dedup(); + Ok(Some(PluginTomlConfig { + value: plugin_toml_runtime_value(value), + dynamic_plugins, + dynamic_plugin_policy, + contributing_sources, + })) + } None => Ok((!dynamic_plugins.is_empty() || dynamic_plugin_policy != DynamicPluginHostPolicy::default()) .then_some(PluginTomlConfig { value: None, dynamic_plugins, dynamic_plugin_policy, + contributing_sources, })), } } @@ -1137,12 +1168,18 @@ fn resolve_dynamic_plugin_refs( 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()))?; + .map_err(|error| { + CliError::Config(format!( + "invalid dynamic plugin manifest referenced by {}: {error}", + source.display() + )) + })?; 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 + "duplicate dynamic plugin id '{}' in {} across plugins.toml sources", + plugin_id, + source.display() ))); } resolved.push(ResolvedDynamicPluginConfig { diff --git a/crates/cli/src/doctor.rs b/crates/cli/src/doctor.rs index d575f554..b272d538 100644 --- a/crates/cli/src/doctor.rs +++ b/crates/cli/src/doctor.rs @@ -28,7 +28,7 @@ use uuid::Uuid; use crate::config::{ AgentConfigs, CodingAgent, DynamicPluginHostConfigStatus, GatewayConfig, ResolvedConfig, - ServerArgs, resolve_server_config, + ServerArgs, default_plugin_config_paths, effective_plugin_toml_sources, resolve_server_config, }; use crate::error::CliError; @@ -66,6 +66,7 @@ pub(crate) struct DoctorReport { pub environment: EnvironmentInfo, pub configuration: ConfigurationInfo, pub agents: Vec, + pub host_plugins: Vec, pub observability: Vec, pub completions: Vec, } @@ -82,12 +83,20 @@ pub(crate) struct ConfigurationInfo { pub workspace: ConfigLayer, pub global: ConfigLayer, pub system: ConfigLayer, + pub plugin_configs: Vec, + pub plugin_resolution: Check, pub resolution: Check, pub default_agent: Option, pub configured_agents: Vec, pub dynamic_plugins: Vec, } +struct PluginConfigurationDiagnostics { + sources: Vec, + error: Option, + resolution: Check, +} + #[derive(Debug, Clone, Serialize)] pub(crate) struct DynamicPluginReferenceInfo { pub plugin_id: String, @@ -143,6 +152,17 @@ pub(crate) async fn collect_report( let cwd = std::env::current_dir().ok(); let home = home_dir(); let configured_agents = configured_agent_names(&resolved.agents); + let (plugin_sources, plugin_error) = match effective_plugin_toml_sources() { + Ok(sources) => (sources, None), + Err(error) => (Vec::new(), Some(error.to_string())), + }; + let plugin_resolution = + plugin_resolution_check(&resolved, &resolution, plugin_error.as_deref()); + let plugin_diagnostics = PluginConfigurationDiagnostics { + sources: plugin_sources, + error: plugin_error, + resolution: plugin_resolution, + }; Ok(DoctorReport { schema_version: 1, @@ -155,8 +175,10 @@ pub(crate) async fn collect_report( resolution, configured_agents, &resolved.dynamic_plugins, + &plugin_diagnostics, ), agents: collect_agents(target_agent, &resolved).await, + host_plugins: crate::plugin_install::collect_default_host_plugin_readiness(), observability: collect_observability(&resolved.gateway).await, completions: collect_completions(home.as_deref()), }) @@ -191,6 +213,7 @@ fn collect_configuration( resolution: Check, configured_agents: Vec, dynamic_plugins: &[crate::config::ResolvedDynamicPluginConfig], + plugin_diagnostics: &PluginConfigurationDiagnostics, ) -> ConfigurationInfo { let workspace_path = cwd .map(|p| p.join(".nemo-relay").join("config.toml")) @@ -207,6 +230,17 @@ fn collect_configuration( workspace: layer_status(&workspace_path), global: layer_status(&global_path), system: layer_status(&system_path), + plugin_configs: default_plugin_config_paths() + .iter() + .map(|path| { + plugin_layer_status( + path, + &plugin_diagnostics.sources, + plugin_diagnostics.error.as_deref(), + ) + }) + .collect(), + plugin_resolution: plugin_diagnostics.resolution.clone(), resolution, // `default_agent` is reserved in the design for Phase 2 dispatch; not currently parsed // out of FileConfig. Doctor reports `None` until that lands. @@ -224,6 +258,50 @@ fn collect_configuration( } } +fn plugin_resolution_check( + resolved: &ResolvedConfig, + resolution: &Check, + plugin_error: Option<&str>, +) -> Check { + if let Some(error) = plugin_error { + return Check { + name: "Plugin resolution", + status: Status::Fail, + details: format!( + "could not resolve plugins.toml: {error}; update the named source and run `nemo-relay plugins edit`" + ), + }; + } + if matches!(resolution.status, Status::Fail) { + return Check { + name: "Plugin resolution", + status: Status::Fail, + details: resolution.details.clone(), + }; + } + if resolved.gateway.plugin_config.is_some() { + Check { + name: "Plugin resolution", + status: Status::Info, + details: "effective plugin configuration loaded; see Plugin validation below".into(), + } + } else if !resolved.dynamic_plugins.is_empty() { + Check { + name: "Plugin resolution", + status: Status::Info, + details: "dynamic plugin configuration loaded; see Dynamic plugin checks below".into(), + } + } else { + Check { + name: "Plugin resolution", + status: Status::Info, + details: + "plugins.toml not configured; run `nemo-relay plugins edit` to configure plugins" + .into(), + } + } +} + fn dynamic_plugin_reference_check(plugin: &DynamicPluginReferenceInfo) -> Check { Check { name: "Dynamic plugin", @@ -288,6 +366,29 @@ fn layer_status(path: &Path) -> ConfigLayer { } } +fn plugin_layer_status( + path: &Path, + contributing_paths: &[PathBuf], + plugin_error: Option<&str>, +) -> ConfigLayer { + let mut layer = layer_status(path); + if let Some(error) = plugin_error.filter(|error| error.contains(&path.display().to_string())) + && matches!(layer.status, Status::Pass) + { + layer.status = Status::Fail; + layer.active = false; + layer.details = format!("invalid plugin configuration: {error}"); + return layer; + } + if layer.active && contributing_paths.iter().any(|source| source == path) { + layer.details = "discovered and contributes to plugin resolution".into(); + } else if layer.active { + layer.active = false; + layer.details = "valid but does not contribute effective plugin configuration".into(); + } + layer +} + async fn collect_agents( target_agent: Option, resolved: &ResolvedConfig, @@ -507,7 +608,7 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { let Some(plugin_value) = &gateway.plugin_config else { checks.push(Check { - name: "Plugins", + name: "Plugin validation", status: Status::Info, details: "plugins.toml not configured".into(), }); @@ -518,7 +619,7 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { Ok(config) => config, Err(err) => { checks.push(Check { - name: "Plugins", + name: "Plugin validation", status: Status::Fail, details: format!("invalid plugin config: {err}"), }); @@ -544,7 +645,7 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec { let report = validate_plugin_config(&plugin_config); if report.diagnostics.is_empty() { checks.push(Check { - name: "Plugins", + name: "Plugin validation", status: Status::Pass, details: "validation passed".into(), }); @@ -1195,9 +1296,11 @@ pub(crate) fn exit_code(report: &DoctorReport) -> u8 { .agents .iter() .any(|agent| matches!(agent.status, Status::Fail)) + || report.host_plugins.iter().any(|plugin| !plugin.ok()) || matches!(report.configuration.workspace.status, Status::Fail) || matches!(report.configuration.global.status, Status::Fail) || matches!(report.configuration.system.status, Status::Fail) + || matches!(report.configuration.plugin_resolution.status, Status::Fail) || matches!(report.configuration.resolution.status, Status::Fail); u8::from(any_fail) } @@ -1215,9 +1318,11 @@ fn report_has_warn(report: &DoctorReport) -> bool { .agents .iter() .any(|agent| matches!(agent.status, Status::Warn)) + || report.host_plugins.iter().any(|plugin| !plugin.ok()) || matches!(report.configuration.workspace.status, Status::Warn) || matches!(report.configuration.global.status, Status::Warn) || matches!(report.configuration.system.status, Status::Warn) + || matches!(report.configuration.plugin_resolution.status, Status::Warn) || matches!(report.configuration.resolution.status, Status::Warn) } @@ -1269,23 +1374,34 @@ 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 - )); + out.push('\n'); + + out.push_str(" Plugin configuration\n"); + for plugin in &report.configuration.dynamic_plugins { + let config_suffix = if matches!( + plugin.host_config_status, + DynamicPluginHostConfigStatus::Present + ) { + "; host config" + } else { + "" + }; + out.push_str(&format!( + " Dynamic {} ({}){}\n", + plugin.plugin_id, plugin.manifest_ref, config_suffix + )); + } + if !report.configuration.plugin_configs.is_empty() { + for (index, layer) in report.configuration.plugin_configs.iter().enumerate() { + let label = if index == 0 { "Plugin files" } else { "" }; + out.push_str(&format!(" {label:<13}{}\n", format_layer(layer))); } } + out.push_str(&format!( + " Plugins {} {}\n", + format_status(report.configuration.plugin_resolution.status), + report.configuration.plugin_resolution.details + )); for plugin in &report.configuration.dynamic_plugins { for check in [ dynamic_plugin_reference_check(plugin), @@ -1326,6 +1442,31 @@ pub(crate) fn format_human(report: &DoctorReport) -> String { } out.push('\n'); + out.push_str(" Host plugins\n"); + if report.host_plugins.is_empty() { + out.push_str(" Β· none installed; run `nemo-relay install ` to enable persistent host plugins\n"); + } else { + for plugin in &report.host_plugins { + out.push_str(&format!( + " {} {}\n", + if plugin.ok() { "βœ“" } else { "βœ—" }, + plugin.host + )); + for check in &plugin.checks { + out.push_str(&format!( + " {} {}: {}\n", + if check.ok { "βœ“" } else { "βœ—" }, + check.name, + check.details + )); + } + if !plugin.ok() { + out.push_str(&format!(" repair: {}\n", plugin.remediation)); + } + } + } + out.push('\n'); + out.push_str(" Observability\n"); for check in &report.observability { out.push_str(&format!(" {:<22} {}\n", check.name, check.details)); diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/plugin_install/host.rs index 3bc2355f..d8c9f092 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/plugin_install/host.rs @@ -7,7 +7,10 @@ use std::env; use std::path::{Path, PathBuf}; use std::process::Command; -use serde_json::{Value, json}; +use serde_json::Value; + +#[cfg(test)] +use serde_json::json; use crate::config::PluginHost; @@ -121,6 +124,7 @@ impl HostRegistrationReport { self.host_plugin_registered && self.host_marketplace_registered } + #[cfg(test)] pub(super) fn to_json(&self) -> Value { json!({ "ok": self.ok(), @@ -130,6 +134,7 @@ impl HostRegistrationReport { } } +#[cfg(test)] pub(super) fn validate_host_registration( host: PluginHost, options: &PluginInstallOptions, diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index 4bc194b5..886e12b2 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -10,7 +10,10 @@ mod state; use std::path::{Path, PathBuf}; use std::process::ExitCode; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; +use serde::Serialize; use serde_json::{Value, json}; use crate::config::{InstallCommand, PluginHost, UninstallCommand}; @@ -19,9 +22,9 @@ use crate::error::CliError; use host::{ CommandRunner, RealCommandRunner, host_registration_report, require_host_cli, require_relay, run_host_marketplace_registration, run_host_marketplace_removal, run_host_plugin_registration, - run_host_plugin_removal, validate_host_registration, validate_relay_plugin_shim, + run_host_plugin_removal, validate_relay_plugin_shim, }; -use marketplace::write_plugin_marketplace; +use marketplace::{marketplace_manifest, plugin_manifest, write_plugin_marketplace}; use setup::{ PluginSetupRunner, RealPluginSetupRunner, run_plugin_doctor, run_plugin_doctor_json, run_plugin_setup, run_plugin_uninstall, @@ -36,6 +39,155 @@ pub(super) const DEFAULT_GATEWAY_URL: &str = "http://127.0.0.1:47632"; pub(super) const MARKETPLACE_NAME: &str = "nemo-relay-local"; pub(super) const PLUGIN_NAME: &str = "nemo-relay-plugin"; pub(super) const RELAY_COMMAND: &str = "nemo-relay"; +const DEFAULT_HOST_PLUGIN_READINESS_TIMEOUT: Duration = Duration::from_secs(5); + +/// One non-mutating readiness check for an installed coding-agent plugin. +/// +/// This is deliberately independent from the CLI doctor's status type so the installer can +/// expose its checks to both the focused and top-level doctor paths without coupling their +/// rendering concerns. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct HostPluginReadinessCheck { + pub(crate) name: String, + pub(crate) ok: bool, + pub(crate) details: String, +} + +/// Readiness state for one persisted host-plugin installation. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct HostPluginReadiness { + pub(crate) host: String, + pub(crate) remediation: String, + pub(crate) state_path: PathBuf, + pub(crate) marketplace: Option, + pub(crate) plugin: Option, + pub(crate) checks: Vec, + #[serde(skip_serializing)] + pub(crate) relay: Option, + #[serde(skip_serializing)] + pub(crate) host_plugin_registered: Option, + #[serde(skip_serializing)] + pub(crate) host_marketplace_registered: Option, + #[serde(skip_serializing)] + pub(crate) plugin_setup: Option, +} + +impl HostPluginReadiness { + pub(crate) fn ok(&self) -> bool { + self.checks.iter().all(|check| check.ok) + } + + fn push(&mut self, name: impl Into, result: Result) { + match result { + Ok(details) => self.checks.push(HostPluginReadinessCheck { + name: name.into(), + ok: true, + details, + }), + Err(details) => self.checks.push(HostPluginReadinessCheck { + name: name.into(), + ok: false, + details, + }), + } + } +} + +struct PendingHostPluginReadiness { + host: PluginHost, + state_path: PathBuf, + receiver: Receiver, +} + +/// Collects default-location host-plugin readiness without printing or mutating state. +/// +/// Only hosts with a persisted install-state record are included. This keeps ordinary +/// transparent-run users from failing the top-level doctor merely because they have not opted +/// into the persistent host-plugin workflow. +pub(crate) fn collect_default_host_plugin_readiness() -> Vec { + let install_dir = default_install_dir().canonicalize_or_self(); + let pending = [PluginHost::Codex, PluginHost::ClaudeCode] + .into_iter() + .filter(|host| state_path(*host, &install_dir).exists()) + .map(|host| spawn_default_host_plugin_readiness(host, install_dir.clone())) + .collect::>(); + let deadline = Instant::now() + DEFAULT_HOST_PLUGIN_READINESS_TIMEOUT; + pending + .into_iter() + .map(|pending| { + receive_host_plugin_readiness( + pending, + deadline.saturating_duration_since(Instant::now()), + ) + }) + .collect() +} + +fn spawn_default_host_plugin_readiness( + host: PluginHost, + install_dir: PathBuf, +) -> PendingHostPluginReadiness { + let state_path = state_path(host, &install_dir); + let (sender, receiver) = mpsc::sync_channel(1); + std::thread::spawn(move || { + let options = PluginInstallOptions { + install_dir, + force: false, + dry_run: false, + skip_doctor: true, + }; + let runner = RealCommandRunner; + let setup_runner = RealPluginSetupRunner; + let readiness = collect_host_plugin_readiness(host, &options, &runner, &setup_runner); + let _ = sender.send(readiness); + }); + PendingHostPluginReadiness { + host, + state_path, + receiver, + } +} + +fn receive_host_plugin_readiness( + pending: PendingHostPluginReadiness, + timeout: Duration, +) -> HostPluginReadiness { + match pending.receiver.recv_timeout(timeout) { + Ok(readiness) => readiness, + Err(mpsc::RecvTimeoutError::Timeout) => failed_host_plugin_readiness( + pending.host, + pending.state_path, + "timed out while collecting host-plugin readiness", + ), + Err(mpsc::RecvTimeoutError::Disconnected) => failed_host_plugin_readiness( + pending.host, + pending.state_path, + "host-plugin readiness collector stopped unexpectedly", + ), + } +} + +fn failed_host_plugin_readiness( + host: PluginHost, + state_path: PathBuf, + details: impl Into, +) -> HostPluginReadiness { + let layout = PluginLayout::new(host, state_path.parent().unwrap_or_else(|| Path::new("."))); + let mut readiness = HostPluginReadiness { + host: host_arg(host).to_string(), + remediation: format!("nemo-relay install {} --force", host_arg(host)), + state_path, + marketplace: Some(layout.marketplace_root), + plugin: Some(layout.plugin_root), + checks: Vec::new(), + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }; + readiness.push("Host readiness", Err(details.into())); + readiness +} pub(crate) fn install(command: InstallCommand) -> Result { let options = PluginInstallOptions { @@ -148,6 +300,9 @@ fn doctor_json(host: PluginHost, options: &PluginInstallOptions) -> Result, _>>() .map_err(CliError::Install)?; + let ready = reports + .iter() + .all(|report| report.get("ok").and_then(Value::as_bool) == Some(true)); if matches!(host, PluginHost::All) { print_json(&json!({ "schema_version": 1, @@ -159,7 +314,11 @@ fn doctor_json(host: PluginHost, options: &PluginInstallOptions) -> Result Result<(), String> { - let relay = require_relay(options, runner)?; - validate_relay_plugin_shim(&relay, options, runner)?; - let state = read_state(host, &options.install_dir) - .ok_or_else(|| format!("no installed {} plugin state found", host_label(host)))?; - println!("nemo-relay: {}", relay.display()); - println!("host: {}", host_arg(host)); - println!("marketplace: {}", state.marketplace_root.display()); - println!("plugin: {}", state.plugin_root.display()); - validate_host_registration(host, options, runner)?; - println!("host plugin registration: ok"); - println!("host marketplace registration: ok"); - run_plugin_doctor(host, options, setup_runner) + let readiness = collect_host_plugin_readiness(host, options, runner, setup_runner); + println!("host: {}", readiness.host); + println!("state: {}", readiness.state_path.display()); + if let Some(path) = &readiness.marketplace { + println!("marketplace: {}", path.display()); + } + if let Some(path) = &readiness.plugin { + println!("plugin: {}", path.display()); + } + for check in &readiness.checks { + let marker = if check.ok { "ok" } else { "failed" }; + println!("{}: {marker} ({})", check.name, check.details); + } + readiness.ok().then_some(()).ok_or_else(|| { + format!( + "{} plugin doctor checks failed; run `nemo-relay install {} --force` to repair the installation", + host_label(host), + host_arg(host) + ) + }) } fn doctor_host_json_value( @@ -325,24 +492,212 @@ fn doctor_host_json_value( runner: &dyn CommandRunner, setup_runner: &dyn PluginSetupRunner, ) -> Result { - let relay = require_relay(options, runner)?; - validate_relay_plugin_shim(&relay, options, runner)?; - let state = read_state(host, &options.install_dir) - .ok_or_else(|| format!("no installed {} plugin state found", host_label(host)))?; - let host_registration = host_registration_report(host, options, runner)?; - let plugin = run_plugin_doctor_json(host, setup_runner)?; - let ok = host_registration.ok() && plugin.get("ok").and_then(Value::as_bool).unwrap_or(false); + let readiness = collect_host_plugin_readiness(host, options, runner, setup_runner); + let host_registration_ok = readiness.host_plugin_registered == Some(true) + && readiness.host_marketplace_registered == Some(true); Ok(json!({ - "ok": ok, - "host": host_arg(host), - "nemo_relay": relay, - "marketplace": state.marketplace_root, - "plugin": state.plugin_root, - "host_registration": host_registration.to_json(), - "checks": plugin + "ok": readiness.ok(), + "host": readiness.host, + "remediation": readiness.remediation, + "nemo_relay": readiness.relay, + "marketplace": readiness.marketplace, + "plugin": readiness.plugin, + "host_registration": { + "ok": host_registration_ok, + "host_plugin_registered": readiness.host_plugin_registered, + "host_marketplace_registered": readiness.host_marketplace_registered + }, + "checks": readiness.plugin_setup, + "state_path": readiness.state_path, + "readiness_checks": readiness.checks })) } +fn collect_host_plugin_readiness( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> HostPluginReadiness { + let state_path = state_path(host, &options.install_dir); + let state = read_state(host, &options.install_dir); + let layout = PluginLayout::new(host, &options.install_dir); + let marketplace = state + .as_ref() + .map(|state| state.marketplace_root.clone()) + .or_else(|| state_path.exists().then(|| layout.marketplace_root.clone())); + let plugin = state + .as_ref() + .map(|state| state.plugin_root.clone()) + .or_else(|| state_path.exists().then(|| layout.plugin_root.clone())); + let mut readiness = HostPluginReadiness { + host: host_arg(host).to_string(), + remediation: format!("nemo-relay install {} --force", host_arg(host)), + state_path: state_path.clone(), + marketplace, + plugin, + checks: Vec::new(), + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }; + + readiness.push( + "Install state", + state + .as_ref() + .map(|_| format!("valid state at {}", state_path.display())) + .ok_or_else(|| format!("missing or invalid state at {}", state_path.display())), + ); + if let Some(marketplace) = readiness.marketplace.as_ref() { + let manifest = marketplace_manifest_path(host, marketplace); + readiness.push( + "Generated marketplace", + generated_manifest_check(&manifest, &marketplace_manifest(host), "marketplace"), + ); + } + if let Some(plugin) = readiness.plugin.as_ref() { + let manifest = plugin_manifest_path(host, plugin); + readiness.push( + "Generated plugin", + generated_manifest_check(&manifest, &plugin_manifest(host), "plugin"), + ); + } + + let relay = require_relay(options, runner); + readiness.push( + "Relay binary", + relay + .as_ref() + .map(|path| format!("found at {}", path.display())) + .map_err(Clone::clone), + ); + if let Ok(relay) = relay { + readiness.relay = Some(relay.clone()); + readiness.push( + "Relay hook support", + validate_relay_plugin_shim(&relay, options, runner) + .map(|_| "plugin-shim hook is supported".into()), + ); + } + + let host_cli_check = require_host_cli(host, options, runner); + readiness.push( + "Host CLI", + host_cli_check + .as_ref() + .map(|_| format!("{} is available", host_cli(host))) + .map_err(Clone::clone), + ); + if host_cli_check.is_ok() { + match host_registration_report(host, options, runner) { + Ok(report) => { + readiness.host_plugin_registered = Some(report.host_plugin_registered); + readiness.host_marketplace_registered = Some(report.host_marketplace_registered); + readiness.push( + "Host registration", + report + .ok() + .then_some("plugin and marketplace registered".into()) + .ok_or_else(|| "plugin or marketplace registration is incomplete".into()), + ); + readiness.push( + "Host plugin registration", + report + .host_plugin_registered + .then_some("registered".into()) + .ok_or_else(|| "nemo-relay host plugin is not registered".into()), + ); + readiness.push( + "Host marketplace registration", + report + .host_marketplace_registered + .then_some("registered".into()) + .ok_or_else(|| "nemo-relay marketplace is not registered".into()), + ); + } + Err(error) => readiness.push("Host registration", Err(error)), + } + } + + match run_plugin_doctor_json(host, setup_runner) { + Ok(plugin_report) => { + append_plugin_setup_checks(&mut readiness, &plugin_report); + readiness.plugin_setup = Some(plugin_report); + } + Err(error) => readiness.push("Host setup", Err(error)), + } + readiness +} + +fn append_plugin_setup_checks(readiness: &mut HostPluginReadiness, report: &Value) { + if let Some(health) = report.get("sidecar_health").and_then(Value::as_str) { + readiness.push("Sidecar health", Ok(health.to_string())); + } + if let Some(checks) = report.get("checks").and_then(Value::as_object) { + for (name, value) in checks { + if name == "sidecar_running" { + continue; + } + let details = name.replace('_', " "); + readiness.push( + details, + value + .as_bool() + .filter(|ok| *ok) + .map(|_| "configured".into()) + .ok_or_else(|| "not configured".into()), + ); + } + } +} + +fn without_version(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + object.remove("version"); + } + value +} + +fn generated_manifest_check(path: &Path, expected: &Value, label: &str) -> Result { + let raw = std::fs::read_to_string(path).map_err(|error| { + format!( + "missing or unreadable {label} manifest {}: {error}", + path.display() + ) + })?; + let actual = serde_json::from_str::(&raw) + .map_err(|error| format!("invalid {label} manifest {}: {error}", path.display()))?; + if without_version(actual) == without_version(expected.clone()) { + Ok(format!("valid at {}", path.display())) + } else { + Err(format!( + "unexpected {label} manifest contents at {}", + path.display() + )) + } +} + +fn marketplace_manifest_path(host: PluginHost, root: &Path) -> PathBuf { + match host { + PluginHost::Codex => root + .join(".agents") + .join("plugins") + .join("marketplace.json"), + PluginHost::ClaudeCode => root.join(".claude-plugin").join("marketplace.json"), + PluginHost::All => unreachable!("all is expanded before layout resolution"), + } +} + +fn plugin_manifest_path(host: PluginHost, root: &Path) -> PathBuf { + match host { + PluginHost::Codex => root.join(".codex-plugin").join("plugin.json"), + PluginHost::ClaudeCode => root.join(".claude-plugin").join("plugin.json"), + PluginHost::All => unreachable!("all is expanded before layout resolution"), + } +} + fn force_cleanup_existing_install( host: PluginHost, layout: &PluginLayout, diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index d4ee2602..d1a3fc3d 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -12,12 +12,52 @@ use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; use serde_json::json; use sha2::{Digest, Sha256}; +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::MutexGuard; use crate::plugins::policy::{ DynamicPluginHostPolicy, DynamicPluginHostPolicyEffect, DynamicPluginHostPolicyRule, evaluate_dynamic_plugin_host_policy, }; +struct PluginConfigDiscoveryScope { + _guard: MutexGuard<'static, ()>, + previous_cwd: PathBuf, + previous_xdg_config_home: Option, +} + +impl PluginConfigDiscoveryScope { + fn enter(cwd: &std::path::Path, xdg_config_home: &std::path::Path) -> Self { + let guard = crate::test_support::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let previous_cwd = std::env::current_dir().unwrap(); + let previous_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME"); + unsafe { + std::env::set_var("XDG_CONFIG_HOME", xdg_config_home); + } + std::env::set_current_dir(cwd).unwrap(); + Self { + _guard: guard, + previous_cwd, + previous_xdg_config_home, + } + } +} + +impl Drop for PluginConfigDiscoveryScope { + fn drop(&mut self) { + std::env::set_current_dir(&self.previous_cwd).unwrap(); + unsafe { + match self.previous_xdg_config_home.take() { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + } +} + fn config() -> GatewayConfig { GatewayConfig { bind: "127.0.0.1:0".parse().unwrap(), @@ -31,6 +71,44 @@ fn config() -> GatewayConfig { } } +#[test] +fn effective_plugin_toml_sources_reports_empty_and_sorted_contributors() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&xdg).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); + + assert_eq!( + effective_plugin_toml_sources().unwrap(), + Vec::::new() + ); + + let project_plugins = project.join(".nemo-relay/plugins.toml"); + let user_plugins = xdg.join("nemo-relay/plugins.toml"); + std::fs::create_dir_all(project_plugins.parent().unwrap()).unwrap(); + std::fs::create_dir_all(user_plugins.parent().unwrap()).unwrap(); + std::fs::write(&project_plugins, "version = 1\ncomponents = []\n").unwrap(); + std::fs::write(&user_plugins, "version = 1\ncomponents = []\n").unwrap(); + + let sources = effective_plugin_toml_sources().unwrap(); + assert!(sources.is_sorted()); + assert!(sources.windows(2).all(|paths| paths[0] != paths[1])); + + let mut actual = sources + .iter() + .map(|path| path.canonicalize().unwrap()) + .collect::>(); + actual.sort(); + let mut expected = [project_plugins, user_plugins] + .iter() + .map(|path| path.canonicalize().unwrap()) + .collect::>(); + expected.sort(); + assert_eq!(actual, expected); +} + fn isolated_config_path(temp: &tempfile::TempDir) -> std::path::PathBuf { temp.path().join("config.toml") } @@ -778,10 +856,12 @@ mode = "strict" ) .unwrap(); - let resolved = load_plugin_toml_config_from_paths(vec![plugins_path]) + let resolved = load_plugin_toml_config_from_paths(vec![plugins_path.clone()]) .unwrap() .unwrap(); + assert!(resolved.contributing_sources.contains(&plugins_path)); + assert_eq!( resolved.value, Some(json!({ @@ -874,10 +954,12 @@ attestation = "signature_required" ) .unwrap(); - let resolved = load_plugin_toml_config_from_paths(vec![plugins_path]) + let resolved = load_plugin_toml_config_from_paths(vec![plugins_path.clone()]) .unwrap() .unwrap(); + assert!(resolved.contributing_sources.contains(&plugins_path)); + assert_eq!( resolved.value, Some(json!({ @@ -1013,11 +1095,17 @@ allowed = true ) .unwrap(); - let resolved = load_plugin_toml_config_from_paths(vec![project_plugins, user_plugins]) - .unwrap() - .unwrap(); + let resolved = + load_plugin_toml_config_from_paths(vec![project_plugins.clone(), user_plugins.clone()]) + .unwrap() + .unwrap(); assert_eq!(resolved.value, None); + assert_eq!( + resolved.contributing_sources, + vec![project_plugins, user_plugins], + "policy-only layers still affect runtime dynamic-plugin behavior" + ); assert_eq!( resolved.dynamic_plugin_policy.defaults.startup, Some(DynamicPluginStartupClass::Required) diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index f9667265..8c7c90d9 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -8,6 +8,8 @@ use std::net::TcpListener; use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use crate::config::ResolvedDynamicPluginConfig; + fn start_doctor_http_capture_server() -> (String, Arc>, std::thread::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); @@ -113,6 +115,12 @@ fn empty_report() -> DoctorReport { active: false, details: "not present".into(), }, + plugin_configs: vec![], + plugin_resolution: Check { + name: "Plugin resolution", + status: Status::Info, + details: "plugins.toml not configured".into(), + }, resolution: Check { name: "Resolution", status: Status::Pass, @@ -123,6 +131,7 @@ fn empty_report() -> DoctorReport { dynamic_plugins: vec![], }, agents: vec![], + host_plugins: vec![], observability: vec![], completions: vec![], } @@ -187,6 +196,43 @@ fn exit_code_fails_when_agent_readiness_fails() { assert_eq!(exit_code(&report), 1); } +#[test] +fn exit_code_fails_when_an_installed_host_plugin_is_unready() { + let mut report = empty_report(); + report + .host_plugins + .push(crate::plugin_install::HostPluginReadiness { + host: "codex".into(), + remediation: "nemo-relay install codex --force".into(), + state_path: PathBuf::from("/tmp/codex.json"), + marketplace: Some(PathBuf::from("/tmp/codex-marketplace")), + plugin: Some(PathBuf::from( + "/tmp/codex-marketplace/plugins/nemo-relay-plugin", + )), + checks: vec![crate::plugin_install::HostPluginReadinessCheck { + name: "Host CLI".into(), + ok: false, + details: "required `codex` CLI was not found on PATH".into(), + }], + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }); + + assert_eq!(exit_code(&report), 1); + let rendered = format_human(&report); + assert!(rendered.contains("Host plugins")); + assert!(rendered.contains("repair: nemo-relay install codex --force")); + let json: serde_json::Value = serde_json::from_str(&format_json(&report).unwrap()).unwrap(); + assert_eq!(json["schema_version"], 1); + assert_eq!(json["host_plugins"][0]["checks"][0]["ok"], false); + assert_eq!( + json["host_plugins"][0]["remediation"], + "nemo-relay install codex --force" + ); +} + #[test] fn format_human_emits_fixed_section_order() { let report = empty_report(); @@ -197,6 +243,9 @@ fn format_human_emits_fixed_section_order() { let cfg_idx = rendered .find("Configuration") .expect("Configuration header"); + let plugins_idx = rendered + .find("Plugin configuration") + .expect("Plugin configuration header"); let agents_idx = rendered.find("Agents detected").expect("Agents header"); let obs_idx = rendered .find("Observability") @@ -204,11 +253,28 @@ fn format_human_emits_fixed_section_order() { let comp_idx = rendered.find("Completions").expect("Completions header"); assert!(env_idx < cfg_idx); - assert!(cfg_idx < agents_idx); + assert!(cfg_idx < plugins_idx); + assert!(plugins_idx < agents_idx); assert!(agents_idx < obs_idx); assert!(obs_idx < comp_idx); } +#[test] +fn format_human_distinguishes_plugin_files_from_plugin_resolution() { + let mut report = empty_report(); + report.configuration.plugin_configs.push(ConfigLayer { + path: PathBuf::from("/tmp/plugins.toml"), + status: Status::Pass, + active: true, + details: "discovered and contributes to plugin resolution".into(), + }); + + let rendered = format_human(&report); + + assert!(rendered.contains("Plugin files /tmp/plugins.toml")); + assert!(rendered.contains("Plugins Β· plugins.toml not configured")); +} + #[test] fn format_human_reports_all_checks_passed_on_clean_report() { let report = empty_report(); @@ -367,6 +433,117 @@ fn layer_status_reports_missing_valid_invalid_and_non_directory_paths() { ); } +#[test] +fn plugin_layer_status_marks_a_discovered_plugins_toml_as_contributing() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("plugins.toml"); + std::fs::write(&path, "version = 1\ncomponents = []\n").unwrap(); + + let layer = plugin_layer_status(&path, std::slice::from_ref(&path), None); + + assert_eq!(layer.status, Status::Pass); + assert!(layer.active); + assert!(layer.details.contains("contributes to plugin resolution")); +} + +#[test] +fn plugin_layer_status_marks_non_contributing_files_as_inactive() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("plugins.toml"); + std::fs::write(&path, "version = 1\ncomponents = []\n").unwrap(); + + let layer = plugin_layer_status(&path, &[], None); + + assert_eq!(layer.status, Status::Pass); + assert!(!layer.active); + assert!(layer.details.contains("does not contribute")); +} + +#[test] +fn plugin_layer_status_surfaces_source_specific_semantic_errors() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("plugins.toml"); + std::fs::write(&path, "[plugins]\ndynamic = 1\n").unwrap(); + let error = format!("invalid dynamic plugin config in {}", path.display()); + + let layer = plugin_layer_status(&path, &[], Some(&error)); + + assert_eq!(layer.status, Status::Fail); + assert!(!layer.active); + assert!(layer.details.contains("invalid plugin configuration")); +} + +#[test] +fn plugin_resolution_check_covers_all_resolution_outcomes() { + let valid = Check { + name: "Resolution", + status: Status::Pass, + details: "valid".into(), + }; + let failed = Check { + name: "Resolution", + status: Status::Fail, + details: "merged configuration failed".into(), + }; + let mut with_runtime_config = ResolvedConfig::default(); + with_runtime_config.gateway.plugin_config = Some(serde_json::json!({"version": 1})); + let mut with_dynamic_plugin = ResolvedConfig::default(); + with_dynamic_plugin + .dynamic_plugins + .push(ResolvedDynamicPluginConfig { + plugin_id: "acme.worker".into(), + manifest_ref: "/tmp/relay-plugin.toml".into(), + config: serde_json::Map::new(), + has_explicit_config: false, + source: PathBuf::from("/tmp/plugins.toml"), + }); + + let cases = [ + ( + ResolvedConfig::default(), + &valid, + Some("invalid plugin TOML"), + Status::Fail, + "could not resolve plugins.toml", + ), + ( + ResolvedConfig::default(), + &failed, + None, + Status::Fail, + "merged configuration failed", + ), + ( + with_runtime_config, + &valid, + None, + Status::Info, + "effective plugin configuration loaded", + ), + ( + with_dynamic_plugin, + &valid, + None, + Status::Info, + "dynamic plugin configuration loaded", + ), + ( + ResolvedConfig::default(), + &valid, + None, + Status::Info, + "plugins.toml not configured", + ), + ]; + + for (resolved, resolution, plugin_error, status, detail) in cases { + let check = plugin_resolution_check(&resolved, resolution, plugin_error); + assert_eq!(check.name, "Plugin resolution"); + assert_eq!(check.status, status); + assert!(check.details.contains(detail)); + } +} + #[test] fn collect_configuration_uses_xdg_global_path_and_renders_resolution_branches() { let temp = tempfile::tempdir().unwrap(); @@ -401,6 +578,15 @@ fn collect_configuration_uses_xdg_global_path_and_renders_resolution_branches() }, vec!["codex".into(), "hermes".into()], &[], + &PluginConfigurationDiagnostics { + sources: vec![], + error: None, + resolution: Check { + name: "Plugin resolution", + status: Status::Pass, + details: "valid".into(), + }, + }, ); assert_eq!(configuration.workspace.status, Status::Pass); @@ -580,6 +766,15 @@ fn configuration_and_path_helpers_cover_direct_paths_and_fallbacks() { }, vec!["codex".into()], &[], + &PluginConfigurationDiagnostics { + sources: vec![], + error: None, + resolution: Check { + name: "Plugin resolution", + status: Status::Pass, + details: "valid".into(), + }, + }, ); assert_eq!(info.workspace.status, Status::Pass); assert!(info.global.path.starts_with(&home)); diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index dc8b706d..5a0776dd 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -286,6 +286,7 @@ fn relay_validation_command() -> String { fn write_installed_state(host: PluginHost, dir: &Path) { let layout = PluginLayout::new(host, dir); + write_plugin_marketplace(host, &layout, &options(dir)).unwrap(); write_state(&layout, &options(dir)).unwrap(); mark_plugin_setup_installed(host, &layout, &options(dir)).unwrap(); } @@ -751,7 +752,7 @@ fn top_level_install_uninstall_and_doctor_report_empty_host_selection() { .unwrap_err() .to_string(); assert!( - codex_doctor_error.contains("required `nemo-relay` executable"), + codex_doctor_error.contains("nemo-relay install codex --force"), "error was: {codex_doctor_error}" ); @@ -1363,6 +1364,196 @@ fn doctor_json_uses_quiet_plugin_report() { ); } +#[test] +fn readiness_report_marks_missing_generated_plugin_files_as_failed() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + "nemo-relay-plugin@nemo-relay-local installed, enabled\n", + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + "nemo-relay-local /tmp/nemo-relay-local\n", + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::remove_file(layout.plugin_manifest).unwrap(); + + let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + assert!(report.checks.iter().any(|check| { + check.name == "Generated plugin" && !check.ok && check.details.contains("missing") + })); + assert_eq!( + setup_runner.calls(), + vec![format!("doctor-json codex {DEFAULT_GATEWAY_URL}")] + ); +} + +#[test] +fn readiness_report_rejects_invalid_generated_manifest_contents() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + "nemo-relay-plugin@nemo-relay-local installed, enabled\n", + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + "nemo-relay-local /tmp/nemo-relay-local\n", + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::write( + &layout.marketplace_manifest, + r#"{"name":"wrong-marketplace"}"#, + ) + .unwrap(); + + let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + + assert!(!report.ok()); + assert!(report.checks.iter().any(|check| { + check.name == "Generated marketplace" && !check.ok && check.details.contains("unexpected") + })); +} + +#[test] +fn readiness_report_accepts_generated_plugin_manifest_from_an_older_version() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list", + "nemo-relay-plugin@nemo-relay-local installed, enabled\n", + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + "nemo-relay-local /tmp/nemo-relay-local\n", + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + let mut manifest = plugin_manifest(PluginHost::Codex); + manifest["version"] = json!("0.0.0"); + std::fs::write( + &layout.plugin_manifest, + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + + let report = collect_host_plugin_readiness(PluginHost::Codex, &options, &runner, &setup_runner); + + assert!(report.ok()); + assert!( + report + .checks + .iter() + .any(|check| check.name == "Generated plugin" && check.ok) + ); +} + +#[test] +fn doctor_json_preserves_unknown_host_registration_state() { + let dir = tempdir().unwrap(); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + + let report = doctor_host_json_value( + PluginHost::Codex, + &options, + &MockRunner::default(), + &setup_runner, + ) + .unwrap(); + + assert_eq!(report["host_registration"]["ok"], json!(false)); + assert!(report["host_registration"]["host_plugin_registered"].is_null()); + assert!(report["host_registration"]["host_marketplace_registered"].is_null()); +} + +#[test] +fn timed_out_host_plugin_readiness_is_actionable() { + let state_path = PathBuf::from("/tmp/nemo-relay/codex.json"); + let (sender, receiver) = mpsc::sync_channel(1); + let _sender = sender; + + let report = receive_host_plugin_readiness( + PendingHostPluginReadiness { + host: PluginHost::Codex, + state_path: state_path.clone(), + receiver, + }, + Duration::ZERO, + ); + + assert!(!report.ok()); + assert_eq!(report.state_path, state_path); + assert_eq!(report.remediation, "nemo-relay install codex --force"); + assert!( + report + .checks + .iter() + .any(|check| check.name == "Host readiness" && !check.ok) + ); +} + +#[test] +fn stopped_lazy_sidecar_does_not_fail_host_readiness() { + let mut readiness = HostPluginReadiness { + host: "codex".into(), + remediation: "nemo-relay install codex --force".into(), + state_path: PathBuf::from("/tmp/codex.json"), + marketplace: None, + plugin: None, + checks: vec![], + relay: None, + host_plugin_registered: None, + host_marketplace_registered: None, + plugin_setup: None, + }; + + append_plugin_setup_checks( + &mut readiness, + &json!({ + "sidecar_health": "not_running_lazy_start", + "checks": { + "plugin_binary": true, + "sidecar_running": false, + "codex_provider_alias": true, + "codex_hooks": true + } + }), + ); + + assert!(readiness.ok()); + assert!( + readiness + .checks + .iter() + .any(|check| check.name == "Sidecar health") + ); + assert!( + !readiness + .checks + .iter() + .any(|check| check.name == "sidecar running") + ); +} + #[test] fn doctor_validates_claude_host_registration_before_setup_doctor() { let dir = tempdir().unwrap(); @@ -1391,7 +1582,7 @@ fn doctor_validates_claude_host_registration_before_setup_doctor() { assert_eq!( setup_runner.calls(), - vec![format!("doctor claude-code {DEFAULT_GATEWAY_URL}")] + vec![format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}")] ); assert_eq!( runner.capture_commands(), @@ -1422,8 +1613,11 @@ fn doctor_fails_when_claude_host_plugin_is_missing() { let error = doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); - assert!(error.contains("nemo-relay-plugin@nemo-relay-local")); - assert!(setup_runner.calls().is_empty()); + assert!(error.contains("nemo-relay install claude-code --force")); + assert_eq!( + setup_runner.calls(), + vec![format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}")] + ); } #[test] @@ -1449,8 +1643,11 @@ fn doctor_fails_when_claude_host_marketplace_is_missing() { let error = doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); - assert!(error.contains("nemo-relay-local host marketplace")); - assert!(setup_runner.calls().is_empty()); + assert!(error.contains("nemo-relay install claude-code --force")); + assert_eq!( + setup_runner.calls(), + vec![format!("doctor-json claude-code {DEFAULT_GATEWAY_URL}")] + ); } #[test] diff --git a/crates/core/README.md b/crates/core/README.md index 4d5b8695..83ce2286 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -26,31 +26,31 @@ Node.js bindings mirror the semantics exposed by this crate. ## Why Use It? -- 🧭 **Own Rust execution context**: Hierarchical scopes preserve parent-child +- **Own Rust execution context**: Hierarchical scopes preserve parent-child relationships across tools, LLM calls, middleware, subscribers, and events. -- πŸ›‘οΈ **Put policy around real calls**: Guardrails and intercepts can block work, +- **Put policy around real calls**: Guardrails and intercepts can block work, sanitize observability payloads, rewrite requests, or wrap execution. -- πŸ“‘ **Emit one lifecycle stream**: Subscribers can consume canonical runtime +- **Emit one lifecycle stream**: Subscribers can consume canonical runtime events in-process or export them to Agent Trajectory Interchange Format (ATIF), OpenTelemetry, and OpenInference. -- 🧩 **Integrate without changing orchestration**: Wrap framework and provider +- **Integrate without changing orchestration**: Wrap framework and provider callbacks while leaving scheduling, retries, memory, and result handling in the owning application. ## What You Get -- βœ… **Managed tool and LLM execution**: Run call boundaries through consistent +- **Managed tool and LLM execution**: Run call boundaries through consistent lifecycle helpers and middleware ordering. -- βœ… **Scope-local runtime behavior**: Attach middleware and subscribers to the +- **Scope-local runtime behavior**: Attach middleware and subscribers to the scope that owns them and clean them up when that scope closes. -- βœ… **Plugin primitives**: Register reusable runtime behavior configured from +- **Plugin primitives**: Register reusable runtime behavior configured from one shared plugin system. -- βœ… **Built-in observability plugin**: Configure first-party Agent Trajectory +- **Built-in observability plugin**: Configure first-party Agent Trajectory Observability Format (ATOF), Agent Trajectory Interchange Format (ATIF), OpenTelemetry, and OpenInference exporters from the core crate. -- βœ… **Codec and typed helpers**: Normalize provider requests and responses for +- **Codec and typed helpers**: Normalize provider requests and responses for framework integrations. -- βœ… **Binding source of truth**: Use the runtime semantics mirrored by the +- **Binding source of truth**: Use the runtime semantics mirrored by the Python and Node.js bindings. ## Installation diff --git a/crates/ffi/README.md b/crates/ffi/README.md index b6d38d7a..3b70e7f1 100644 --- a/crates/ffi/README.md +++ b/crates/ffi/README.md @@ -25,27 +25,27 @@ binding consumes it through CGo. ## Why Use It? -- πŸ”Œ **Expose NeMo Relay to native consumers**: Call the shared Rust runtime from +- **Expose NeMo Relay to native consumers**: Call the shared Rust runtime from C-compatible hosts and downstream language bindings. -- 🧱 **Build on one ABI**: Keep native integrations aligned with the same scope, +- **Build on one ABI**: Keep native integrations aligned with the same scope, middleware, lifecycle event, and observability contract. -- πŸ“¦ **Consume a generated C header**: Use the committed `nemo_relay.h` surface +- **Consume a generated C header**: Use the committed `nemo_relay.h` surface produced by the crate build. -- 🚧 **Work source-first**: Use this experimental surface when Rust, Python, and +- **Work source-first**: Use this experimental surface when Rust, Python, and Node.js packages are not the right integration layer. ## What You Get -- βœ… **Exported `nemo_relay_*` symbols**: APIs for scopes, tool calls, LLM calls, +- **Exported `nemo_relay_*` symbols**: APIs for scopes, tool calls, LLM calls, middleware, subscribers, plugins, observability exporters, and scope stack isolation. -- βœ… **Generated header**: A committed `nemo_relay.h` file for C-compatible +- **Generated header**: A committed `nemo_relay.h` file for C-compatible consumers. -- βœ… **Native library outputs**: Shared and static libraries for platform +- **Native library outputs**: Shared and static libraries for platform linking. -- βœ… **JSON payload contract**: Cross-language request, response, metadata, and +- **JSON payload contract**: Cross-language request, response, metadata, and event data carried as JSON. -- βœ… **Go binding foundation**: The repository-maintained Go binding consumes +- **Go binding foundation**: The repository-maintained Go binding consumes this ABI through CGo. ## Installation diff --git a/crates/node/README.md b/crates/node/README.md index a573d75d..8a131f83 100644 --- a/crates/node/README.md +++ b/crates/node/README.md @@ -25,26 +25,26 @@ should install it from npm rather than depend on the Rust crate directly. ## Why Use It? -- 🧭 **Own execution context in Node.js**: Group agent, tool, and LLM work into +- **Own execution context in Node.js**: Group agent, tool, and LLM work into one scope tree from JavaScript or TypeScript. -- πŸ›‘οΈ **Put policy around callbacks**: Register guardrails and intercepts for +- **Put policy around callbacks**: Register guardrails and intercepts for request rewriting, blocking, sanitization, and execution wrapping. -- πŸ“‘ **Emit one lifecycle stream**: Send runtime events to in-process +- **Emit one lifecycle stream**: Send runtime events to in-process subscribers, Agent Trajectory Interchange Format (ATIF), OpenTelemetry, or OpenInference workflows. -- 🧩 **Use package entry points by need**: Import the main runtime surface plus +- **Use package entry points by need**: Import the main runtime surface plus typed, plugin, adaptive, and observability helpers from npm. ## What You Get -- βœ… **npm package for Node.js**: A Node.js 24 or newer package backed by a +- **npm package for Node.js**: A Node.js 24 or newer package backed by a napi-rs native extension. -- βœ… **Managed tool and LLM execution**: Helpers that emit lifecycle events and +- **Managed tool and LLM execution**: Helpers that emit lifecycle events and run middleware in a consistent order. -- βœ… **Middleware APIs**: Guardrails and intercepts for tool and LLM boundaries. -- βœ… **Observability exporters**: Subscriber and exporter support for common +- **Middleware APIs**: Guardrails and intercepts for tool and LLM boundaries. +- **Observability exporters**: Subscriber and exporter support for common runtime telemetry flows. -- βœ… **Additional entry points**: `nemo-relay-node/typed`, +- **Additional entry points**: `nemo-relay-node/typed`, `nemo-relay-node/plugin`, `nemo-relay-node/adaptive`, and `nemo-relay-node/observability`. diff --git a/crates/python/README.md b/crates/python/README.md index 493d6af9..8b9dab58 100644 --- a/crates/python/README.md +++ b/crates/python/README.md @@ -25,23 +25,23 @@ crate directly. ## Why Use It? -- 🧩 **Bridge Python to the shared runtime**: Connect Python applications to the +- **Bridge Python to the shared runtime**: Connect Python applications to the Rust NeMo Relay runtime without reimplementing runtime semantics in Python. -- πŸ› οΈ **Build through standard Python packaging**: Use the repository +- **Build through standard Python packaging**: Use the repository `pyproject.toml`, Maturin, and PyO3 to produce the native extension behind `nemo-relay`. -- πŸ” **Keep binding behavior aligned**: Expose the same scopes, middleware, +- **Keep binding behavior aligned**: Expose the same scopes, middleware, plugins, lifecycle events, and adaptive helpers used by the rest of NeMo Relay. ## What You Get -- βœ… **Native extension**: The compiled `nemo_relay._native` module used by the +- **Native extension**: The compiled `nemo_relay._native` module used by the public Python package. -- βœ… **Runtime APIs for Python**: Access to scopes, tool calls, LLM calls, +- **Runtime APIs for Python**: Access to scopes, tool calls, LLM calls, middleware, subscribers, plugins, typed helpers, codecs, and adaptive helpers. -- βœ… **Shared Rust semantics**: Python behavior backed by the same runtime +- **Shared Rust semantics**: Python behavior backed by the same runtime contract as the Rust crate. -- βœ… **Local development path**: `uv sync` builds the editable package and native +- **Local development path**: `uv sync` builds the editable package and native extension from the repository root. ## Installation diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index d88f5429..081dd0a0 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -100,10 +100,23 @@ nemo-relay doctor --plugin codex nemo-relay doctor --plugin all ``` -The plugin doctor checks host registration, generated marketplace state, provider -routing, hook setup, and sidecar readiness assumptions. It is separate from -`nemo-relay doctor codex`, which diagnoses the regular transparent-run -configuration for an agent. +`nemo-relay doctor` includes every persistent host-plugin installation found in +the default platform install directory. It checks the generated marketplace and +plugin files, Relay binary and hook support, host registration, provider +routing, hooks, and lazy-sidecar assumptions. A stopped Codex lazy sidecar is +informational; hooks start it on first use. + +Use the focused plugin doctor when diagnosing one host or an installation that +uses a custom directory: + +```bash +nemo-relay doctor --plugin codex --install-dir /path/to/plugins +``` + +If an installed host plugin is incomplete, doctor reports the failed check and +suggests `nemo-relay install --force`. Hosts without a persistent plugin +installation remain informational, so transparent-run setup does not require a +host plugin. ## Uninstall diff --git a/go/nemo_relay/README.md b/go/nemo_relay/README.md index 6ec5a147..81baaba8 100644 --- a/go/nemo_relay/README.md +++ b/go/nemo_relay/README.md @@ -26,26 +26,26 @@ primary supported surfaces. ## Why Use It? -- 🧭 **Use NeMo Relay from Go**: Group agent, tool, and LLM work into the same +- **Use NeMo Relay from Go**: Group agent, tool, and LLM work into the same scope and lifecycle model as the Rust runtime. -- πŸ”Œ **Bridge through CGo and FFI**: Consume the shared runtime through the +- **Bridge through CGo and FFI**: Consume the shared runtime through the repository-maintained `nemo-relay-ffi` layer. -- πŸ“‘ **Observe runtime behavior**: Register subscribers for scope, tool, LLM, +- **Observe runtime behavior**: Register subscribers for scope, tool, LLM, and mark events emitted by the runtime. -- 🚧 **Evaluate an experimental binding**: Use the source-first Go surface when +- **Evaluate an experimental binding**: Use the source-first Go surface when a Go integration needs NeMo Relay semantics. ## What You Get -- βœ… **Scope, tool, and LLM helpers**: Managed lifecycle APIs backed by the +- **Scope, tool, and LLM helpers**: Managed lifecycle APIs backed by the shared Rust runtime. -- βœ… **Middleware APIs**: Guardrails and intercepts for request rewriting, +- **Middleware APIs**: Guardrails and intercepts for request rewriting, blocking, sanitization, and execution wrapping. -- βœ… **Event subscribers**: Runtime lifecycle callbacks for observability and +- **Event subscribers**: Runtime lifecycle callbacks for observability and diagnostics. -- βœ… **Convenience subpackages**: Short imports for scopes, tools, LLM calls, +- **Convenience subpackages**: Short imports for scopes, tools, LLM calls, guardrails, intercepts, subscribers, plugins, and adaptive helpers. -- βœ… **Local source-first workflow**: Build the FFI library locally, then test or +- **Local source-first workflow**: Build the FFI library locally, then test or consume the Go module from the checkout. ## Installation diff --git a/python/nemo_relay/README.md b/python/nemo_relay/README.md index 1678dd93..6d6e2fac 100644 --- a/python/nemo_relay/README.md +++ b/python/nemo_relay/README.md @@ -26,27 +26,27 @@ runtime semantics as the Rust and Node.js surfaces. ## Why Use It? -- 🧭 **Own execution context in Python**: Group agent, tool, and LLM work into +- **Own execution context in Python**: Group agent, tool, and LLM work into one scope tree from Python application code. -- πŸ›‘οΈ **Package policy around callbacks**: Use guardrails and intercepts to block +- **Package policy around callbacks**: Use guardrails and intercepts to block work, sanitize observability payloads, rewrite requests, or wrap execution. -- πŸ“‘ **Emit one lifecycle stream**: Send runtime events to in-process +- **Emit one lifecycle stream**: Send runtime events to in-process subscribers, Agent Trajectory Interchange Format (ATIF), OpenTelemetry, or OpenInference workflows. -- 🧩 **Integrate without a framework migration**: Wrap framework or provider +- **Integrate without a framework migration**: Wrap framework or provider callbacks while preserving the application’s orchestration model. ## What You Get -- βœ… **Scope, tool, and LLM helpers**: Managed boundaries that emit lifecycle +- **Scope, tool, and LLM helpers**: Managed boundaries that emit lifecycle events and run middleware in a consistent order. -- βœ… **Middleware APIs**: Guardrails and intercepts for tool and LLM requests, +- **Middleware APIs**: Guardrails and intercepts for tool and LLM requests, responses, and execution. -- βœ… **Subscribers and exporters**: Event consumers for observability and +- **Subscribers and exporters**: Event consumers for observability and diagnostics. -- βœ… **Plugin and typed helpers**: Public modules for plugins, codecs, typed +- **Plugin and typed helpers**: Public modules for plugins, codecs, typed wrappers, adaptive runtime behavior, and observability plugin configuration. -- βœ… **Shared Rust runtime semantics**: Python behavior aligned with the Rust +- **Shared Rust runtime semantics**: Python behavior aligned with the Rust and Node.js surfaces. ## Installation