use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use crate::error::ManifestError;
pub const PLUGIN_MANIFEST_FILENAME: &str = "nexo-plugin.toml";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginManifest {
#[serde(default = "default_manifest_version")]
pub manifest_version: u32,
pub plugin: PluginSection,
}
fn default_manifest_version() -> u32 {
1
}
pub const CURRENT_MANIFEST_VERSION: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginSection {
pub id: String,
pub version: Version,
pub name: String,
pub description: String,
pub min_nexo_version: VersionReq,
#[serde(default)]
pub enabled_by_default: bool,
#[serde(default)]
pub capabilities: Capabilities,
#[serde(default)]
pub tools: ToolsSection,
#[serde(default)]
pub advisors: AdvisorsSection,
#[serde(default)]
pub agents: AgentsSection,
#[serde(default)]
pub channels: ChannelsSection,
#[serde(default)]
pub skills: SkillsSection,
#[serde(default)]
pub config: ConfigSection,
#[serde(default)]
pub config_schema: Option<ConfigSchemaSection>,
#[serde(default)]
pub credentials_schema: Option<CredentialsSchemaSection>,
#[serde(default)]
pub extends: ExtendsSection,
#[serde(default)]
pub requires: RequiresSection,
#[serde(default)]
pub capability_gates: CapabilityGatesSection,
#[serde(default)]
pub ui: UiSection,
#[serde(
default,
skip_serializing_if = "crate::pairing::PairingSection::is_unset"
)]
pub pairing: crate::pairing::PairingSection,
#[serde(default)]
pub contracts: ContractsSection,
#[serde(default)]
pub meta: MetaSection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dashboard: Option<crate::dashboard::PluginDashboardSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metrics: Option<crate::metrics::PluginMetricsSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin: Option<crate::admin::PluginAdminSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http: Option<crate::http::PluginHttpSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_ui: Option<crate::admin_ui::PluginAdminUiSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poller: Option<crate::poller::PluginPollerSection>,
#[serde(
default,
skip_serializing_if = "crate::public_tunnel::PluginPublicTunnelSection::is_unset"
)]
pub public_tunnel: crate::public_tunnel::PluginPublicTunnelSection,
#[serde(default)]
pub entrypoint: EntrypointSection,
#[serde(default)]
pub supervisor: SupervisorSection,
#[serde(default)]
pub sandbox: crate::sandbox::SandboxSection,
#[serde(default)]
pub subscriptions: SubscriptionsSection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http_server: Option<toml::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EntrypointSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
}
impl EntrypointSection {
pub fn is_subprocess(&self) -> bool {
self.command
.as_deref()
.map(|c| !c.trim().is_empty())
.unwrap_or(false)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SupervisorSection {
#[serde(default = "default_supervisor_respawn")]
pub respawn: bool,
#[serde(default = "default_supervisor_max_attempts")]
pub max_attempts: u32,
#[serde(default = "default_supervisor_backoff_ms")]
pub backoff_ms: u64,
#[serde(default = "default_supervisor_stderr_tail")]
pub stderr_tail_lines: usize,
}
fn default_supervisor_respawn() -> bool {
false
}
fn default_supervisor_max_attempts() -> u32 {
3
}
fn default_supervisor_backoff_ms() -> u64 {
1_000
}
fn default_supervisor_stderr_tail() -> usize {
32
}
impl Default for SupervisorSection {
fn default() -> Self {
Self {
respawn: default_supervisor_respawn(),
max_attempts: default_supervisor_max_attempts(),
backoff_ms: default_supervisor_backoff_ms(),
stderr_tail_lines: default_supervisor_stderr_tail(),
}
}
}
pub const SUPERVISOR_STDERR_TAIL_MAX: usize = 512;
pub const SUPERVISOR_BACKOFF_MS_MIN: u64 = 100;
pub const SUPERVISOR_BACKOFF_MS_MAX: u64 = 300_000;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Capabilities {
#[serde(default)]
pub provides: Vec<Capability>,
#[serde(default)]
pub admin: AdminCapabilities,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http_server: Option<HttpServerCapability>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub broker: Option<BrokerCapability>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct BrokerCapability {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subscribe: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub publish: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct HttpServerCapability {
pub port: u16,
#[serde(default = "default_http_bind")]
pub bind: String,
pub token_env: String,
#[serde(default = "default_health_path")]
pub health_path: String,
#[serde(default)]
pub extra_env_passthrough: Vec<String>,
}
fn default_http_bind() -> String {
"127.0.0.1".to_string()
}
fn default_health_path() -> String {
"/healthz".to_string()
}
impl HttpServerCapability {
pub fn is_loopback(&self) -> bool {
matches!(self.bind.as_str(), "127.0.0.1" | "::1" | "localhost")
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct SubscriptionsSection {
#[serde(default)]
pub broker_topics: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct AdminCapabilities {
#[serde(default)]
pub required: Vec<String>,
#[serde(default)]
pub optional: Vec<String>,
}
impl AdminCapabilities {
pub fn declared(&self) -> std::collections::HashSet<String> {
self.required
.iter()
.chain(self.optional.iter())
.cloned()
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Capability {
Tools,
Advisors,
Agents,
Skills,
Channels,
Hooks,
McpServers,
Webhooks,
PollerDrivers,
LlmProviders,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolsSection {
#[serde(default)]
pub expose: Vec<String>,
#[serde(default)]
pub deferred: Vec<String>,
#[serde(default)]
pub outbound: Vec<OutboundToolSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OutboundToolSpec {
pub name: String,
pub description: String,
pub input_schema: String,
#[serde(default = "default_outbound_rpc_method")]
pub rpc_method: String,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
fn default_outbound_rpc_method() -> String {
"outbound_tool.invoke".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdvisorsSection {
#[serde(default)]
pub register: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentsSection {
#[serde(default)]
pub contributes_dir: Option<PathBuf>,
#[serde(default)]
pub allow_override: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChannelsSection {
#[serde(default)]
pub register: Vec<ChannelDecl>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChannelDecl {
pub kind: String,
pub adapter: String,
}
pub const EXTENDS_SECTIONS: &[&str] = &[
"channels",
"llm_providers",
"memory_backends",
"hooks",
"tools",
];
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ExtendsSection {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub llm_providers: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub memory_backends: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
}
impl ExtendsSection {
pub fn is_empty(&self) -> bool {
self.channels.is_empty()
&& self.llm_providers.is_empty()
&& self.memory_backends.is_empty()
&& self.hooks.is_empty()
&& self.tools.is_empty()
}
pub fn all_ids(&self) -> Vec<(&'static str, &str)> {
let mut out = Vec::with_capacity(
self.channels.len()
+ self.llm_providers.len()
+ self.memory_backends.len()
+ self.hooks.len()
+ self.tools.len(),
);
for id in &self.channels {
out.push(("channels", id.as_str()));
}
for id in &self.llm_providers {
out.push(("llm_providers", id.as_str()));
}
for id in &self.memory_backends {
out.push(("memory_backends", id.as_str()));
}
for id in &self.hooks {
out.push(("hooks", id.as_str()));
}
for id in &self.tools {
out.push(("tools", id.as_str()));
}
out
}
pub fn registers(&self, section: &str, id: &str) -> bool {
match section {
"channels" => self.channels.iter().any(|s| s == id),
"llm_providers" => self.llm_providers.iter().any(|s| s == id),
"memory_backends" => self.memory_backends.iter().any(|s| s == id),
"hooks" => self.hooks.iter().any(|s| s == id),
"tools" => self.tools.iter().any(|s| s == id),
_ => false,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SkillsSection {
#[serde(default)]
pub contributes_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigSection {
#[serde(default)]
pub schema_path: Option<PathBuf>,
#[serde(default = "default_true")]
pub hot_reload: bool,
}
impl Default for ConfigSection {
fn default() -> Self {
Self {
schema_path: None,
hot_reload: true,
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigSchemaSection {
pub schema: String,
pub shape: ConfigShape,
#[serde(default = "default_true")]
pub hot_reload: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfigShape {
Object,
Array,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CredentialsSchemaSection {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub accounts_shape: Option<ConfigShape>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ContributedSkillError {
#[error("contributed skill `{name}` declared in plugin.toml has no `skills/{name}/SKILL.md`")]
Missing { name: String },
#[error("contributed skill name `{name}` is not a valid slug ([a-z0-9-], max 64 chars)")]
InvalidSlug { name: String },
}
const SKILL_NAME_MAX: usize = 64;
fn is_valid_skill_slug(name: &str) -> bool {
if name.is_empty() || name.len() > SKILL_NAME_MAX {
return false;
}
name.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
pub fn validate_contributed_skills(
caps: &Capabilities,
plugin_root: &std::path::Path,
) -> Result<Vec<PathBuf>, ContributedSkillError> {
let mut out = Vec::with_capacity(caps.skills.len());
for name in &caps.skills {
if !is_valid_skill_slug(name) {
return Err(ContributedSkillError::InvalidSlug { name: name.clone() });
}
let candidate = plugin_root.join("skills").join(name).join("SKILL.md");
if !candidate.is_file() {
return Err(ContributedSkillError::Missing { name: name.clone() });
}
out.push(candidate);
}
Ok(out)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RequiresSection {
#[serde(default)]
pub bins: Vec<String>,
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub features: Vec<String>,
#[serde(default)]
pub nexo_capabilities: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CapabilityGatesSection {
#[serde(default, rename = "gate")]
pub gates: Vec<CapabilityGateDecl>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CapabilityGateDecl {
pub extension: String,
pub env_var: String,
pub kind: GateKind,
pub risk: GateRisk,
pub effect: String,
#[serde(default)]
pub hint: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum GateKind {
Boolean,
CargoFeature,
Allowlist,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum GateRisk {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiSection {
#[serde(default)]
pub fields: BTreeMap<String, UiHint>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiHint {
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub help: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub advanced: bool,
#[serde(default)]
pub sensitive: bool,
#[serde(default)]
pub placeholder: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContractsSection {
#[serde(default)]
pub provides: Vec<String>,
#[serde(default)]
pub consumes: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetaSection {
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub repository: Option<String>,
}
impl PluginManifest {
#[allow(clippy::should_implement_trait)]
pub fn from_str(toml_src: &str) -> Result<Self, ManifestError> {
let (parsed, was_v1) = crate::compat_v1::try_parse_v2_or_v1(toml_src)?;
if was_v1 {
crate::compat_v1::emit_v1_deprecation_warning(
&parsed.plugin.id,
&parsed.plugin.version.to_string(),
);
}
Ok(parsed)
}
pub fn from_path(path: &Path) -> Result<Self, ManifestError> {
let raw = std::fs::read_to_string(path)?;
Self::from_str(&raw)
}
pub fn parse_validated(
toml_src: &str,
current_nexo_version: &Version,
) -> Result<Self, Vec<ManifestError>> {
let parsed = Self::from_str(toml_src).map_err(|e| vec![e])?;
parsed.validate(current_nexo_version)?;
Ok(parsed)
}
pub fn from_path_validated(
path: &Path,
current_nexo_version: &Version,
) -> Result<Self, Vec<ManifestError>> {
let raw = std::fs::read_to_string(path).map_err(|e| vec![ManifestError::Io(e)])?;
Self::parse_validated(&raw, current_nexo_version)
}
pub fn validate(&self, current_nexo_version: &Version) -> Result<(), Vec<ManifestError>> {
let mut errors = Vec::new();
crate::validate::run_all(self, current_nexo_version, &mut errors);
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn id(&self) -> &str {
&self.plugin.id
}
pub fn version(&self) -> &Version {
&self.plugin.version
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_manifest_toml() -> &'static str {
r#"
[plugin]
id = "marketing"
version = "0.1.0"
name = "Marketing"
description = "Lead pipeline plugin"
min_nexo_version = ">=0.1.0"
"#
}
#[test]
fn parse_minimal_valid_manifest() {
let m = PluginManifest::from_str(minimal_manifest_toml()).unwrap();
assert_eq!(m.id(), "marketing");
assert_eq!(m.version().to_string(), "0.1.0");
assert!(!m.plugin.enabled_by_default, "default opt-in is false");
assert!(m.plugin.subscriptions.broker_topics.is_empty());
}
#[test]
fn parse_top_level_plugin_http_server_block_accepted_as_opaque() {
let raw = r#"
[plugin]
id = "marketing"
version = "0.1.0"
name = "Marketing"
description = "Lead pipeline"
min_nexo_version = ">=0.1.0"
[plugin.http_server]
port_env = "MARKETING_HTTP_PORT"
default_port = 18766
bind = "127.0.0.1"
token_env = "MARKETING_ADMIN_TOKEN"
health_path = "/healthz"
"#;
let m = PluginManifest::from_str(raw).unwrap();
let block = m.plugin.http_server.expect("http_server block");
assert_eq!(
block.get("port_env").and_then(|v| v.as_str()),
Some("MARKETING_HTTP_PORT"),
);
assert_eq!(
block.get("default_port").and_then(|v| v.as_integer()),
Some(18766),
);
}
#[test]
fn parse_plugin_subscriptions_broker_topics() {
let raw = r#"
[plugin]
id = "marketing"
version = "0.1.0"
name = "Marketing"
description = "Lead pipeline"
min_nexo_version = ">=0.1.0"
[plugin.subscriptions]
broker_topics = [
"plugin.inbound.email.*",
"agent.email.notification.*",
]
"#;
let m = PluginManifest::from_str(raw).unwrap();
assert_eq!(
m.plugin.subscriptions.broker_topics,
vec![
"plugin.inbound.email.*".to_string(),
"agent.email.notification.*".to_string(),
]
);
}
#[test]
fn parse_full_manifest_round_trip() {
let toml_src = r#"
[plugin]
id = "marketing"
version = "0.1.0"
name = "Marketing"
description = "Lead pipeline"
min_nexo_version = ">=0.1.0"
enabled_by_default = false
[plugin.capabilities]
provides = ["tools", "advisors"]
[plugin.tools]
expose = ["marketing_lead_classify", "marketing_lead_route"]
deferred = ["marketing_lead_classify"]
[plugin.advisors]
register = ["MarketingAdvisor"]
[plugin.requires]
env = ["MARKETING_API_KEY"]
"#;
let m = PluginManifest::from_str(toml_src).unwrap();
assert_eq!(m.plugin.tools.expose.len(), 2);
assert_eq!(m.plugin.tools.deferred.len(), 1);
assert_eq!(m.plugin.capabilities.provides.len(), 2);
assert_eq!(m.plugin.requires.env, vec!["MARKETING_API_KEY".to_string()]);
}
#[test]
fn reject_unknown_field() {
let toml_src = r#"
[plugin]
id = "marketing"
version = "0.1.0"
name = "x"
description = "x"
min_nexo_version = ">=0.1.0"
something_unknown = true
"#;
let result = PluginManifest::from_str(toml_src);
assert!(result.is_err(), "unknown field must be rejected");
}
#[test]
fn reject_missing_required_id() {
let toml_src = r#"
[plugin]
version = "0.1.0"
name = "x"
description = "x"
min_nexo_version = ">=0.1.0"
"#;
assert!(PluginManifest::from_str(toml_src).is_err());
}
#[test]
fn reject_missing_required_version() {
let toml_src = r#"
[plugin]
id = "marketing"
name = "x"
description = "x"
min_nexo_version = ">=0.1.0"
"#;
assert!(PluginManifest::from_str(toml_src).is_err());
}
#[test]
fn reject_invalid_version_string() {
let toml_src = r#"
[plugin]
id = "marketing"
version = "not.a.semver"
name = "x"
description = "x"
min_nexo_version = ">=0.1.0"
"#;
assert!(PluginManifest::from_str(toml_src).is_err());
}
#[test]
fn default_enabled_by_default_false() {
let m = PluginManifest::from_str(minimal_manifest_toml()).unwrap();
assert!(!m.plugin.enabled_by_default);
}
#[test]
fn default_config_hot_reload_true() {
let m = PluginManifest::from_str(minimal_manifest_toml()).unwrap();
assert!(m.plugin.config.hot_reload, "hot_reload defaults true");
}
#[test]
fn example_marketing_manifest_validates() {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("examples")
.join("marketing-example.toml");
let m = PluginManifest::from_path(&path).expect("reference manifest must parse");
let current = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
m.validate(¤t)
.unwrap_or_else(|errs| panic!("reference manifest must validate: {errs:?}"));
}
#[test]
fn capability_serde_round_trip_via_toml() {
let cases = [
(Capability::Tools, "tools"),
(Capability::Advisors, "advisors"),
(Capability::Agents, "agents"),
(Capability::Skills, "skills"),
(Capability::Channels, "channels"),
(Capability::Hooks, "hooks"),
(Capability::McpServers, "mcp_servers"),
(Capability::Webhooks, "webhooks"),
(Capability::PollerDrivers, "poller_drivers"),
(Capability::LlmProviders, "llm_providers"),
];
for (variant, snake) in cases {
let toml_src = format!("provides = [\"{snake}\"]\n");
let parsed: Capabilities = toml::from_str(&toml_src).unwrap();
assert_eq!(parsed.provides, vec![variant], "round-trip {variant:?}");
}
}
#[test]
fn http_server_defaults_loopback_and_healthz() {
let toml_src = r#"
[http_server]
port = 9001
token_env = "AGENT_CREATOR_TOKEN"
"#;
let parsed: Capabilities = toml::from_str(toml_src).unwrap();
let http = parsed.http_server.expect("http_server present");
assert_eq!(http.port, 9001);
assert_eq!(http.bind, "127.0.0.1");
assert_eq!(http.health_path, "/healthz");
assert_eq!(http.token_env, "AGENT_CREATOR_TOKEN");
assert!(http.is_loopback());
}
#[test]
fn http_server_external_bind_round_trips() {
let toml_src = r#"
[http_server]
port = 8080
bind = "0.0.0.0"
token_env = "TOK"
health_path = "/health"
"#;
let parsed: Capabilities = toml::from_str(toml_src).unwrap();
let http = parsed.http_server.expect("http_server present");
assert_eq!(http.bind, "0.0.0.0");
assert!(!http.is_loopback());
assert_eq!(http.health_path, "/health");
}
#[test]
fn http_server_absent_when_unset() {
let parsed: Capabilities = toml::from_str("provides = []").unwrap();
assert!(parsed.http_server.is_none());
}
#[test]
fn http_server_rejects_unknown_field() {
let toml_src = r#"
[http_server]
port = 9001
token_env = "T"
extra = true
"#;
let err = toml::from_str::<Capabilities>(toml_src).unwrap_err();
assert!(err.to_string().contains("extra"), "got: {err}");
}
#[test]
fn contributed_skills_field_round_trips() {
let toml_src = r#"
provides = []
skills = ["ventas-flujo", "soporte-flujo"]
"#;
let parsed: Capabilities = toml::from_str(toml_src).unwrap();
assert_eq!(parsed.skills, vec!["ventas-flujo", "soporte-flujo"]);
}
#[test]
fn contributed_skills_default_empty() {
let parsed: Capabilities = toml::from_str("provides = []").unwrap();
assert!(parsed.skills.is_empty());
}
#[test]
fn contributed_skills_skip_serialise_when_empty() {
let caps = Capabilities {
provides: Vec::new(),
admin: AdminCapabilities::default(),
http_server: None,
skills: Vec::new(),
broker: None,
};
let serialised = toml::to_string(&caps).unwrap();
assert!(
!serialised.contains("skills"),
"empty skills must not appear:\n{serialised}"
);
}
#[test]
fn validate_contributed_skills_happy_path() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("skills").join("ventas-flujo");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# ventas-flujo").unwrap();
let caps = Capabilities {
provides: Vec::new(),
admin: AdminCapabilities::default(),
http_server: None,
skills: vec!["ventas-flujo".into()],
broker: None,
};
let paths = validate_contributed_skills(&caps, tmp.path()).expect("ok");
assert_eq!(paths.len(), 1);
assert!(paths[0].ends_with("ventas-flujo/SKILL.md"));
}
#[test]
fn validate_contributed_skills_missing_file_errors() {
let tmp = tempfile::tempdir().unwrap();
let caps = Capabilities {
provides: Vec::new(),
admin: AdminCapabilities::default(),
http_server: None,
skills: vec!["ghost".into()],
broker: None,
};
let err = validate_contributed_skills(&caps, tmp.path()).unwrap_err();
assert_eq!(
err,
ContributedSkillError::Missing {
name: "ghost".into()
}
);
}
#[test]
fn validate_contributed_skills_rejects_path_traversal_slug() {
let tmp = tempfile::tempdir().unwrap();
let caps = Capabilities {
provides: Vec::new(),
admin: AdminCapabilities::default(),
http_server: None,
skills: vec!["../../etc/passwd".into()],
broker: None,
};
let err = validate_contributed_skills(&caps, tmp.path()).unwrap_err();
match err {
ContributedSkillError::InvalidSlug { name } => {
assert_eq!(name, "../../etc/passwd");
}
other => panic!("expected InvalidSlug, got {other:?}"),
}
}
#[test]
fn validate_contributed_skills_rejects_uppercase_or_special_chars() {
let tmp = tempfile::tempdir().unwrap();
for bad in ["VentasFlujo", "ventas_flujo", "ventas flujo", ""] {
let caps = Capabilities {
provides: Vec::new(),
admin: AdminCapabilities::default(),
http_server: None,
skills: vec![bad.into()],
broker: None,
};
assert!(
matches!(
validate_contributed_skills(&caps, tmp.path()),
Err(ContributedSkillError::InvalidSlug { .. })
),
"expected `{bad}` to be rejected as invalid slug"
);
}
}
#[test]
fn validate_contributed_skills_empty_list_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let caps = Capabilities {
provides: Vec::new(),
admin: AdminCapabilities::default(),
http_server: None,
skills: Vec::new(),
broker: None,
};
let paths = validate_contributed_skills(&caps, tmp.path()).unwrap();
assert!(paths.is_empty());
}
#[test]
fn manifest_extends_section_parses_minimal() {
let toml_src = r#"
[plugin]
id = "ext_one"
version = "0.1.0"
name = "Ext One"
description = "x"
min_nexo_version = ">=0.1.0"
[plugin.extends]
llm_providers = ["cohere"]
"#;
let m = PluginManifest::from_str(toml_src).unwrap();
assert_eq!(m.plugin.extends.llm_providers, vec!["cohere".to_string()]);
assert!(m.plugin.extends.channels.is_empty());
assert!(m.plugin.extends.memory_backends.is_empty());
assert!(m.plugin.extends.hooks.is_empty());
assert!(!m.plugin.extends.is_empty());
}
#[test]
fn manifest_extends_section_parses_full() {
let toml_src = r#"
[plugin]
id = "ext_full"
version = "0.1.0"
name = "Ext Full"
description = "x"
min_nexo_version = ">=0.1.0"
[plugin.extends]
channels = ["slack", "discord"]
llm_providers = ["cohere", "mistral"]
memory_backends = ["pinecone"]
hooks = ["pii_redact"]
"#;
let m = PluginManifest::from_str(toml_src).unwrap();
assert_eq!(m.plugin.extends.channels.len(), 2);
assert_eq!(m.plugin.extends.llm_providers.len(), 2);
assert_eq!(m.plugin.extends.memory_backends.len(), 1);
assert_eq!(m.plugin.extends.hooks.len(), 1);
assert!(m.plugin.extends.registers("channels", "slack"));
assert!(m.plugin.extends.registers("llm_providers", "mistral"));
assert!(!m.plugin.extends.registers("hooks", "absent"));
assert!(!m.plugin.extends.registers("unknown_section", "anything"));
let ids = m.plugin.extends.all_ids();
assert_eq!(ids.len(), 6);
assert_eq!(ids[0], ("channels", "slack"));
assert_eq!(ids[1], ("channels", "discord"));
assert_eq!(ids[2], ("llm_providers", "cohere"));
assert_eq!(ids[5], ("hooks", "pii_redact"));
}
#[test]
fn manifest_extends_section_defaults_empty_when_absent() {
let m = PluginManifest::from_str(minimal_manifest_toml()).unwrap();
assert!(m.plugin.extends.is_empty());
assert!(m.plugin.extends.all_ids().is_empty());
}
#[test]
fn manifest_extends_section_serializes_round_trip() {
let extends = ExtendsSection {
channels: vec!["slack".into()],
llm_providers: vec!["cohere".into(), "mistral".into()],
memory_backends: Vec::new(),
hooks: vec!["pii_redact".into()],
tools: Vec::new(),
};
let s = toml::to_string(&extends).unwrap();
assert!(!s.contains("memory_backends"));
assert!(s.contains("channels = [\"slack\"]"));
assert!(s.contains("hooks = [\"pii_redact\"]"));
let parsed: ExtendsSection = toml::from_str(&s).unwrap();
assert_eq!(parsed, extends);
}
#[test]
fn manifest_extends_tools_round_trip() {
let toml_src = r#"
[plugin]
id = "browser"
version = "0.1.0"
name = "Browser"
description = "x"
min_nexo_version = ">=0.1.0"
[plugin.extends]
tools = ["browser_navigate", "browser_click"]
"#;
let m = PluginManifest::from_str(toml_src).unwrap();
assert_eq!(m.plugin.extends.tools.len(), 2);
assert!(m.plugin.extends.registers("tools", "browser_navigate"));
assert!(!m.plugin.extends.registers("tools", "browser_screenshot"));
}
#[test]
fn manifest_extends_tools_empty_when_absent() {
let m = PluginManifest::from_str(minimal_manifest_toml()).unwrap();
assert!(m.plugin.extends.tools.is_empty());
}
#[test]
fn manifest_extends_section_all_ids_includes_tools_in_order() {
let toml_src = r#"
[plugin]
id = "browser"
version = "0.1.0"
name = "Browser"
description = "x"
min_nexo_version = ">=0.1.0"
[plugin.extends]
channels = ["c1"]
tools = ["browser_t1", "browser_t2"]
"#;
let m = PluginManifest::from_str(toml_src).unwrap();
let ids = m.plugin.extends.all_ids();
assert_eq!(ids[0], ("channels", "c1"));
assert_eq!(ids[1], ("tools", "browser_t1"));
assert_eq!(ids[2], ("tools", "browser_t2"));
}
#[test]
fn manifest_extends_section_round_trip_with_tools() {
let extends = ExtendsSection {
channels: Vec::new(),
llm_providers: Vec::new(),
memory_backends: Vec::new(),
hooks: Vec::new(),
tools: vec!["browser_navigate".into(), "browser_click".into()],
};
let s = toml::to_string(&extends).unwrap();
assert!(s.contains("tools = [\"browser_navigate\", \"browser_click\"]"));
let parsed: ExtendsSection = toml::from_str(&s).unwrap();
assert_eq!(parsed, extends);
}
}