1use std::collections::HashMap;
55use std::path::{Path, PathBuf};
56
57use serde::{Deserialize, Serialize};
58
59use crate::error::{PluginError, PluginResult};
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Platform {
69 Macos,
70 Windows,
71 Linux,
72}
73
74impl Platform {
75 pub fn current() -> Option<Platform> {
79 Self::parse(std::env::consts::OS)
80 }
81
82 pub fn as_str(self) -> &'static str {
83 match self {
84 Platform::Macos => "macos",
85 Platform::Windows => "windows",
86 Platform::Linux => "linux",
87 }
88 }
89
90 pub fn parse(value: &str) -> Option<Platform> {
93 match value {
94 "macos" => Some(Platform::Macos),
95 "windows" => Some(Platform::Windows),
96 "linux" => Some(Platform::Linux),
97 _ => None,
98 }
99 }
100}
101
102impl std::fmt::Display for Platform {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str(self.as_str())
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct McpServerManifestEntry {
114 pub id: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub name: Option<String>,
118 #[serde(default = "default_true")]
119 pub enabled: bool,
120 pub transport: McpTransportManifest,
121 #[serde(default)]
122 pub allowed_tools: Vec<String>,
123 #[serde(default)]
124 pub denied_tools: Vec<String>,
125}
126
127fn default_true() -> bool {
128 true
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(tag = "type", rename_all = "snake_case")]
138pub enum McpTransportManifest {
139 Stdio {
140 command: String,
142 #[serde(default)]
143 args: Vec<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 cwd: Option<String>,
147 #[serde(default)]
149 env: HashMap<String, String>,
150 },
151 Sse {
152 url: String,
153 #[serde(default)]
154 headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
155 },
156 #[serde(rename = "streamable_http")]
157 StreamableHttp {
158 url: String,
159 #[serde(default)]
160 headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
161 },
162}
163
164impl McpServerManifestEntry {
165 pub fn resolve(
173 &self,
174 plugin_dir: &Path,
175 plugin_id: &str,
176 platform: Platform,
177 ) -> PluginResult<bamboo_domain::mcp_config::McpServerConfig> {
178 use bamboo_domain::mcp_config::{
179 default_connect_timeout, default_healthcheck_interval, default_request_timeout,
180 default_startup_timeout, McpServerConfig, ReconnectConfig, SseConfig, StdioConfig,
181 StreamableHttpConfig, TransportConfig,
182 };
183
184 let transport = match &self.transport {
185 McpTransportManifest::Stdio {
186 command,
187 args,
188 cwd,
189 env,
190 } => {
191 if command.trim().is_empty() {
192 return Err(PluginError::InvalidManifest(format!(
193 "mcp server '{}' has an empty stdio command",
194 self.id
195 )));
196 }
197 TransportConfig::Stdio(StdioConfig {
198 command: substitute_tokens(command, plugin_dir, plugin_id, platform),
199 args: args
200 .iter()
201 .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform))
202 .collect(),
203 cwd: cwd
204 .as_deref()
205 .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform)),
206 env: env
207 .iter()
208 .map(|(key, value)| {
209 (
210 key.clone(),
211 substitute_tokens(value, plugin_dir, plugin_id, platform),
212 )
213 })
214 .collect(),
215 env_encrypted: HashMap::new(),
216 startup_timeout_ms: default_startup_timeout(),
217 })
218 }
219 McpTransportManifest::Sse { url, headers } => TransportConfig::Sse(SseConfig {
220 url: url.clone(),
221 headers: headers.clone(),
222 connect_timeout_ms: default_connect_timeout(),
223 }),
224 McpTransportManifest::StreamableHttp { url, headers } => {
225 TransportConfig::StreamableHttp(StreamableHttpConfig {
226 url: url.clone(),
227 headers: headers.clone(),
228 connect_timeout_ms: default_connect_timeout(),
229 })
230 }
231 };
232
233 Ok(McpServerConfig {
234 id: self.id.clone(),
235 name: self.name.clone(),
236 enabled: self.enabled,
237 transport,
238 request_timeout_ms: default_request_timeout(),
239 healthcheck_interval_ms: default_healthcheck_interval(),
240 reconnect: ReconnectConfig::default(),
241 allowed_tools: self.allowed_tools.clone(),
242 denied_tools: self.denied_tools.clone(),
243 })
244 }
245}
246
247pub fn substitute_tokens(
252 template: &str,
253 plugin_dir: &Path,
254 plugin_id: &str,
255 platform: Platform,
256) -> String {
257 let plugin_dir_str = plugin_dir.to_string_lossy();
258 let platform_bin_str = platform_bin_path(plugin_dir, plugin_id, platform)
259 .to_string_lossy()
260 .into_owned();
261 template
262 .replace("${plugin_dir}", plugin_dir_str.as_ref())
263 .replace("${platform_bin}", &platform_bin_str)
264}
265
266pub fn platform_bin_path(plugin_dir: &Path, plugin_id: &str, platform: Platform) -> PathBuf {
269 let filename = if matches!(platform, Platform::Windows) {
270 format!("{plugin_id}.exe")
271 } else {
272 plugin_id.to_string()
273 };
274 plugin_dir
275 .join("bin")
276 .join(platform.as_str())
277 .join(filename)
278}
279
280pub const PLATFORM_BIN_TOKEN: &str = "${platform_bin}";
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "snake_case")]
292pub enum HealthCheckKind {
293 ProcessAlive,
294 Tcp,
295 Http,
296}
297
298fn default_health_interval_ms() -> u64 {
299 15_000
300}
301
302fn default_health_timeout_ms() -> u64 {
303 5_000
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct HealthCheckSpec {
309 pub kind: HealthCheckKind,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub target: Option<String>,
314 #[serde(default = "default_health_interval_ms")]
315 pub interval_ms: u64,
316 #[serde(default = "default_health_timeout_ms")]
317 pub timeout_ms: u64,
318}
319
320impl Default for HealthCheckSpec {
321 fn default() -> Self {
322 Self {
323 kind: HealthCheckKind::ProcessAlive,
324 target: None,
325 interval_ms: default_health_interval_ms(),
326 timeout_ms: default_health_timeout_ms(),
327 }
328 }
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case")]
337pub enum ShutdownSignal {
338 #[default]
339 Term,
340 None,
341}
342
343fn default_shutdown_timeout_ms() -> u64 {
344 5_000
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct GracefulShutdown {
350 #[serde(default)]
351 pub signal: ShutdownSignal,
352 #[serde(default = "default_shutdown_timeout_ms")]
355 pub timeout_ms: u64,
356}
357
358impl Default for GracefulShutdown {
359 fn default() -> Self {
360 Self {
361 signal: ShutdownSignal::default(),
362 timeout_ms: default_shutdown_timeout_ms(),
363 }
364 }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct ServiceManifestEntry {
382 pub id: String,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub name: Option<String>,
387 #[serde(default = "default_true")]
388 pub enabled: bool,
389 pub command: String,
391 #[serde(default)]
392 pub args: Vec<String>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub cwd: Option<String>,
396 #[serde(default)]
398 pub env: HashMap<String, String>,
399 #[serde(default)]
400 pub health_check: HealthCheckSpec,
401 #[serde(default)]
405 pub restart_policy: bamboo_domain::mcp_config::ReconnectConfig,
406 #[serde(default)]
407 pub graceful_shutdown: GracefulShutdown,
408}
409
410#[derive(Debug, Clone)]
415pub struct ResolvedServiceEntry {
416 pub id: String,
417 pub name: Option<String>,
418 pub enabled: bool,
419 pub command: PathBuf,
420 pub args: Vec<String>,
421 pub cwd: Option<PathBuf>,
422 pub env: HashMap<String, String>,
423 pub health_check: HealthCheckSpec,
424 pub restart_policy: bamboo_domain::mcp_config::ReconnectConfig,
425 pub graceful_shutdown: GracefulShutdown,
426}
427
428impl ServiceManifestEntry {
429 pub fn resolve(
436 &self,
437 plugin_dir: &Path,
438 plugin_id: &str,
439 platform: Platform,
440 ) -> ResolvedServiceEntry {
441 ResolvedServiceEntry {
442 id: self.id.clone(),
443 name: self.name.clone(),
444 enabled: self.enabled,
445 command: platform_bin_path(plugin_dir, plugin_id, platform),
446 args: self
447 .args
448 .iter()
449 .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform))
450 .collect(),
451 cwd: self.cwd.as_deref().map(|value| {
452 PathBuf::from(substitute_tokens(value, plugin_dir, plugin_id, platform))
453 }),
454 env: self
455 .env
456 .iter()
457 .map(|(key, value)| {
458 (
459 key.clone(),
460 substitute_tokens(value, plugin_dir, plugin_id, platform),
461 )
462 })
463 .collect(),
464 health_check: self.health_check.clone(),
465 restart_policy: self.restart_policy.clone(),
466 graceful_shutdown: self.graceful_shutdown.clone(),
467 }
468 }
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct PluginPromptPreset {
478 pub id: String,
479 pub name: String,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub description: Option<String>,
482 pub content: String,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct PluginArtifact {
506 pub url: String,
508 pub sha256: String,
511}
512
513#[derive(Debug, Clone, Default, Serialize, Deserialize)]
516pub struct PluginProvides {
517 #[serde(default, skip_serializing_if = "Vec::is_empty")]
518 pub mcp_servers: Vec<McpServerManifestEntry>,
519 #[serde(default, skip_serializing_if = "Vec::is_empty")]
525 pub skills: Vec<String>,
526 #[serde(default, skip_serializing_if = "Vec::is_empty")]
527 pub prompts: Vec<PluginPromptPreset>,
528 #[serde(default, skip_serializing_if = "Vec::is_empty")]
532 pub workflows: Vec<String>,
533 #[serde(default, skip_serializing_if = "Vec::is_empty")]
536 pub services: Vec<ServiceManifestEntry>,
537}
538
539impl PluginProvides {
540 pub fn is_empty(&self) -> bool {
541 self.mcp_servers.is_empty()
542 && self.skills.is_empty()
543 && self.prompts.is_empty()
544 && self.workflows.is_empty()
545 && self.services.is_empty()
546 }
547}
548
549#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct PluginManifest {
552 pub id: String,
555 pub name: String,
556 pub version: String,
561 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub description: Option<String>,
563 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub bamboo_min_version: Option<String>,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub platforms: Option<Vec<Platform>>,
571 #[serde(default)]
572 pub provides: PluginProvides,
573 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
579 pub artifacts: HashMap<String, PluginArtifact>,
580}
581
582const MAX_PLUGIN_ID_LEN: usize = 64;
583const MAX_PRESET_ID_LEN: usize = 80;
584
585const RESERVED_PRESET_IDS: &[&str] = &["general_assistant"];
593
594pub fn is_valid_plugin_id(id: &str) -> bool {
599 !id.is_empty()
600 && id.len() <= MAX_PLUGIN_ID_LEN
601 && id
602 .chars()
603 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
604}
605
606pub fn is_valid_preset_id(id: &str) -> bool {
612 !id.is_empty()
613 && id.len() <= MAX_PRESET_ID_LEN
614 && !RESERVED_PRESET_IDS.contains(&id)
615 && id
616 .chars()
617 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
618}
619
620pub fn is_plausible_semver(value: &str) -> bool {
626 let core = value.split(['-', '+']).next().unwrap_or_default();
627 let parts: Vec<&str> = core.split('.').collect();
628 parts.len() == 3
629 && parts
630 .iter()
631 .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
632}
633
634fn is_safe_relative_name(name: &str) -> bool {
638 !name.is_empty()
639 && !name.contains('/')
640 && !name.contains('\\')
641 && !name.contains("..")
642 && !name.chars().any(|ch| ch.is_control())
643}
644
645impl PluginManifest {
646 pub fn parse_str(content: &str) -> PluginResult<Self> {
651 serde_json::from_str(content).map_err(PluginError::from)
652 }
653
654 pub fn validate(&self) -> PluginResult<()> {
658 if !is_valid_plugin_id(&self.id) {
659 return Err(PluginError::InvalidManifest(format!(
660 "invalid plugin id '{}': must be [a-z0-9-_], <= {} chars",
661 self.id, MAX_PLUGIN_ID_LEN
662 )));
663 }
664 if self.name.trim().is_empty() {
665 return Err(PluginError::InvalidManifest(
666 "plugin name must not be empty".to_string(),
667 ));
668 }
669 if !is_plausible_semver(&self.version) {
670 return Err(PluginError::InvalidManifest(format!(
671 "invalid plugin version '{}': expected major.minor.patch[-pre][+build]",
672 self.version
673 )));
674 }
675 if let Some(min_version) = &self.bamboo_min_version {
676 if !is_plausible_semver(min_version) {
677 return Err(PluginError::InvalidManifest(format!(
678 "invalid bamboo_min_version '{min_version}'"
679 )));
680 }
681 }
682 if let Some(platforms) = &self.platforms {
683 if platforms.is_empty() {
684 return Err(PluginError::InvalidManifest(
685 "platforms, if present, must not be empty (use `null`/omit for \"all platforms\")"
686 .to_string(),
687 ));
688 }
689 }
690
691 let mut seen_mcp_ids = std::collections::HashSet::new();
692 for entry in &self.provides.mcp_servers {
693 if entry.id.trim().is_empty() {
694 return Err(PluginError::InvalidManifest(
695 "mcp server entries must have a non-empty id".to_string(),
696 ));
697 }
698 if !seen_mcp_ids.insert(entry.id.clone()) {
699 return Err(PluginError::InvalidManifest(format!(
700 "duplicate mcp server id '{}' in provides.mcp_servers",
701 entry.id
702 )));
703 }
704 if let McpTransportManifest::Stdio { command, .. } = &entry.transport {
705 if command.trim().is_empty() {
706 return Err(PluginError::InvalidManifest(format!(
707 "mcp server '{}' has an empty stdio command",
708 entry.id
709 )));
710 }
711 }
712 }
713
714 let mut seen_service_ids = std::collections::HashSet::new();
715 for entry in &self.provides.services {
716 if entry.id.trim().is_empty() {
717 return Err(PluginError::InvalidManifest(
718 "service entries must have a non-empty id".to_string(),
719 ));
720 }
721 if !seen_service_ids.insert(entry.id.clone()) {
722 return Err(PluginError::InvalidManifest(format!(
723 "duplicate service id '{}' in provides.services",
724 entry.id
725 )));
726 }
727 if entry.command.trim().is_empty() {
728 return Err(PluginError::InvalidManifest(format!(
729 "service '{}' has an empty command",
730 entry.id
731 )));
732 }
733 if entry.command != PLATFORM_BIN_TOKEN {
738 return Err(PluginError::InvalidManifest(format!(
739 "service '{}' command must be exactly '{PLATFORM_BIN_TOKEN}' — services may \
740 only execute the plugin's own verified per-platform binary, never an \
741 arbitrary command",
742 entry.id
743 )));
744 }
745 match entry.health_check.kind {
746 HealthCheckKind::Tcp | HealthCheckKind::Http => {
747 let target_ok = entry
748 .health_check
749 .target
750 .as_deref()
751 .map(|value| !value.trim().is_empty())
752 .unwrap_or(false);
753 if !target_ok {
754 return Err(PluginError::InvalidManifest(format!(
755 "service '{}' health_check.kind={:?} requires a non-empty target",
756 entry.id, entry.health_check.kind
757 )));
758 }
759 }
760 HealthCheckKind::ProcessAlive => {}
761 }
762 }
763
764 for skill_dir in &self.provides.skills {
765 if !is_safe_relative_name(skill_dir) {
766 return Err(PluginError::InvalidManifest(format!(
767 "invalid skill directory name '{skill_dir}' in provides.skills"
768 )));
769 }
770 }
771
772 let mut seen_preset_ids = std::collections::HashSet::new();
773 for preset in &self.provides.prompts {
774 if !is_valid_preset_id(&preset.id) {
775 return Err(PluginError::InvalidManifest(format!(
776 "invalid prompt preset id '{}': must be [a-z0-9_], <= {} chars",
777 preset.id, MAX_PRESET_ID_LEN
778 )));
779 }
780 if !seen_preset_ids.insert(preset.id.clone()) {
781 return Err(PluginError::InvalidManifest(format!(
782 "duplicate prompt preset id '{}' in provides.prompts",
783 preset.id
784 )));
785 }
786 if preset.name.trim().is_empty() {
787 return Err(PluginError::InvalidManifest(format!(
788 "prompt preset '{}' has an empty name",
789 preset.id
790 )));
791 }
792 if preset.content.trim().is_empty() {
793 return Err(PluginError::InvalidManifest(format!(
794 "prompt preset '{}' has empty content",
795 preset.id
796 )));
797 }
798 }
799
800 for workflow_file in &self.provides.workflows {
801 if !is_safe_relative_name(workflow_file) || !workflow_file.ends_with(".md") {
802 return Err(PluginError::InvalidManifest(format!(
803 "invalid workflow filename '{workflow_file}' in provides.workflows (must be a bare '<name>.md')"
804 )));
805 }
806 }
807
808 for (platform_key, artifact) in &self.artifacts {
809 let Some(artifact_platform) = Platform::parse(platform_key) else {
810 return Err(PluginError::InvalidManifest(format!(
811 "unknown platform key '{platform_key}' in artifacts (expected macos/windows/linux)"
812 )));
813 };
814 if let Some(gate) = &self.platforms {
818 if !gate.contains(&artifact_platform) {
819 return Err(PluginError::InvalidManifest(format!(
820 "artifacts contains platform '{platform_key}' which is not in the \
821 `platforms` gate {:?}",
822 gate.iter()
823 .map(|platform| platform.as_str())
824 .collect::<Vec<_>>()
825 )));
826 }
827 }
828 if artifact.url.trim().is_empty() {
829 return Err(PluginError::InvalidManifest(format!(
830 "artifact for platform '{platform_key}' has an empty url"
831 )));
832 }
833 let sha_is_hex64 = artifact.sha256.len() == 64
834 && artifact.sha256.chars().all(|ch| ch.is_ascii_hexdigit());
835 if !sha_is_hex64 {
836 return Err(PluginError::InvalidManifest(format!(
837 "artifact for platform '{platform_key}' has an invalid sha256 (expected 64 lowercase hex chars)"
838 )));
839 }
840 }
841
842 if !self.artifacts.is_empty() && self.uses_platform_bin_token() {
851 for platform in self.effective_platforms() {
852 if !self.artifacts.contains_key(platform.as_str()) {
853 return Err(PluginError::InvalidManifest(format!(
854 "plugin uses ${{platform_bin}} and ships URL artifacts, but has no \
855 artifact for supported platform '{}' (every supported platform needs a \
856 downloadable binary bundle)",
857 platform.as_str()
858 )));
859 }
860 }
861 }
862
863 Ok(())
864 }
865
866 pub fn supports_platform(&self, platform: Platform) -> bool {
869 match &self.platforms {
870 None => true,
871 Some(platforms) => platforms.contains(&platform),
872 }
873 }
874
875 pub fn effective_platforms(&self) -> Vec<Platform> {
879 self.platforms
880 .clone()
881 .unwrap_or_else(|| vec![Platform::Macos, Platform::Windows, Platform::Linux])
882 }
883
884 pub fn uses_platform_bin_token(&self) -> bool {
889 const TOKEN: &str = PLATFORM_BIN_TOKEN;
890 let mcp_uses = self.provides.mcp_servers.iter().any(|entry| {
891 let McpTransportManifest::Stdio {
892 command,
893 args,
894 cwd,
895 env,
896 } = &entry.transport
897 else {
898 return false;
899 };
900 command.contains(TOKEN)
901 || args.iter().any(|value| value.contains(TOKEN))
902 || cwd.as_deref().is_some_and(|value| value.contains(TOKEN))
903 || env.values().any(|value| value.contains(TOKEN))
904 });
905 let service_uses = self.provides.services.iter().any(|entry| {
911 entry.command.contains(TOKEN)
912 || entry.args.iter().any(|value| value.contains(TOKEN))
913 || entry
914 .cwd
915 .as_deref()
916 .is_some_and(|value| value.contains(TOKEN))
917 || entry.env.values().any(|value| value.contains(TOKEN))
918 });
919 mcp_uses || service_uses
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926
927 fn minimal_manifest_json() -> &'static str {
928 r#"{
929 "id": "hello-plugin",
930 "name": "Hello Plugin",
931 "version": "0.1.0",
932 "provides": {
933 "skills": ["hello-world"],
934 "prompts": [
935 {"id": "hello_preset", "name": "Hello Preset", "content": "Say hello."}
936 ]
937 }
938 }"#
939 }
940
941 #[test]
942 fn parses_minimal_manifest() {
943 let manifest = PluginManifest::parse_str(minimal_manifest_json()).expect("parse");
944 assert_eq!(manifest.id, "hello-plugin");
945 assert_eq!(manifest.version, "0.1.0");
946 assert_eq!(manifest.provides.skills, vec!["hello-world".to_string()]);
947 assert_eq!(manifest.provides.prompts.len(), 1);
948 assert!(manifest.provides.mcp_servers.is_empty());
949 assert!(manifest.artifacts.is_empty());
950 manifest.validate().expect("minimal manifest is valid");
951 }
952
953 #[test]
954 fn parses_full_manifest_with_mcp_and_artifacts() {
955 let json = r#"{
956 "id": "nova_plugin",
957 "name": "Nova",
958 "version": "1.2.3-beta+build.7",
959 "description": "Desktop control MCP server",
960 "bamboo_min_version": "2026.7.0",
961 "platforms": ["macos", "windows", "linux"],
962 "provides": {
963 "mcp_servers": [
964 {
965 "id": "nova",
966 "enabled": true,
967 "transport": {
968 "type": "stdio",
969 "command": "${platform_bin}",
970 "args": ["--serve"],
971 "cwd": "${plugin_dir}",
972 "env": {"NOVA_HOME": "${plugin_dir}/data"}
973 }
974 }
975 ],
976 "workflows": ["daily-report.md"]
977 },
978 "artifacts": {
979 "macos": {"url": "https://example.com/nova-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
980 "windows": {"url": "https://example.com/nova-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
981 "linux": {"url": "https://example.com/nova-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
982 }
983 }"#;
984
985 let manifest = PluginManifest::parse_str(json).expect("parse full manifest");
986 manifest.validate().expect("full manifest is valid");
987 assert!(manifest.supports_platform(Platform::Macos));
988 assert!(manifest.supports_platform(Platform::Windows));
989 assert!(manifest.supports_platform(Platform::Linux));
990
991 let entry = &manifest.provides.mcp_servers[0];
992 let plugin_dir = Path::new("/home/user/.bamboo/plugins/nova_plugin");
993 let resolved = entry
994 .resolve(plugin_dir, &manifest.id, Platform::Macos)
995 .expect("resolve mcp entry");
996 match resolved.transport {
997 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
998 assert_eq!(
999 stdio.command,
1000 "/home/user/.bamboo/plugins/nova_plugin/bin/macos/nova_plugin"
1001 );
1002 assert_eq!(stdio.cwd.as_deref(), Some(plugin_dir.to_str().unwrap()));
1003 assert_eq!(
1004 stdio.env.get("NOVA_HOME").map(String::as_str),
1005 Some("/home/user/.bamboo/plugins/nova_plugin/data")
1006 );
1007 }
1008 _ => panic!("expected stdio transport"),
1009 }
1010 }
1011
1012 #[test]
1013 fn platform_bin_path_appends_exe_on_windows_only() {
1014 let dir = Path::new("/plugins/demo");
1015 assert_eq!(
1016 platform_bin_path(dir, "demo", Platform::Macos),
1017 PathBuf::from("/plugins/demo/bin/macos/demo")
1018 );
1019 assert_eq!(
1020 platform_bin_path(dir, "demo", Platform::Windows),
1021 PathBuf::from("/plugins/demo/bin/windows/demo.exe")
1022 );
1023 assert_eq!(
1024 platform_bin_path(dir, "demo", Platform::Linux),
1025 PathBuf::from("/plugins/demo/bin/linux/demo")
1026 );
1027 }
1028
1029 #[test]
1030 fn rejects_invalid_id() {
1031 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1032 manifest.id = "Bad Id!".to_string();
1033 let error = manifest.validate().expect_err("bad id should fail");
1034 assert!(error.to_string().contains("invalid plugin id"));
1035 }
1036
1037 #[test]
1038 fn rejects_bad_semver() {
1039 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1040 manifest.version = "latest".to_string();
1041 let error = manifest.validate().expect_err("bad version should fail");
1042 assert!(error.to_string().contains("invalid plugin version"));
1043 }
1044
1045 #[test]
1046 fn rejects_empty_platforms_list() {
1047 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1048 manifest.platforms = Some(vec![]);
1049 let error = manifest
1050 .validate()
1051 .expect_err("empty platforms should fail");
1052 assert!(error.to_string().contains("platforms"));
1053 }
1054
1055 #[test]
1056 fn rejects_duplicate_mcp_server_ids() {
1057 let json = r#"{
1058 "id": "dup",
1059 "name": "Dup",
1060 "version": "1.0.0",
1061 "provides": {
1062 "mcp_servers": [
1063 {"id": "a", "transport": {"type": "stdio", "command": "x"}},
1064 {"id": "a", "transport": {"type": "stdio", "command": "y"}}
1065 ]
1066 }
1067 }"#;
1068 let manifest = PluginManifest::parse_str(json).unwrap();
1069 let error = manifest
1070 .validate()
1071 .expect_err("duplicate mcp id should fail");
1072 assert!(error.to_string().contains("duplicate mcp server id"));
1073 }
1074
1075 #[test]
1076 fn rejects_traversal_in_skill_dir_and_bad_workflow_filename() {
1077 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1078 manifest.provides.skills = vec!["../escape".to_string()];
1079 assert!(manifest.validate().is_err());
1080
1081 let mut manifest2: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1082 manifest2.provides.skills = vec![];
1083 manifest2.provides.workflows = vec!["not-markdown.txt".to_string()];
1084 assert!(manifest2.validate().is_err());
1085 }
1086
1087 #[test]
1088 fn rejects_invalid_artifact_sha256() {
1089 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1090 manifest.artifacts.insert(
1091 "macos".to_string(),
1092 PluginArtifact {
1093 url: "https://example.com/x.tar.gz".to_string(),
1094 sha256: "not-hex".to_string(),
1095 },
1096 );
1097 let error = manifest.validate().expect_err("bad sha256 should fail");
1098 assert!(error.to_string().contains("sha256"));
1099 }
1100
1101 #[test]
1102 fn rejects_platform_bin_plugin_missing_an_artifact_for_a_supported_platform() {
1103 let json = r#"{
1107 "id": "binbacked",
1108 "name": "Bin Backed",
1109 "version": "1.0.0",
1110 "provides": {
1111 "mcp_servers": [
1112 {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1113 ]
1114 },
1115 "artifacts": {
1116 "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1117 "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1118 }
1119 }"#;
1120 let manifest = PluginManifest::parse_str(json).unwrap();
1121 let error = manifest
1122 .validate()
1123 .expect_err("missing linux artifact should fail");
1124 assert!(error.to_string().contains("linux"));
1125 }
1126
1127 #[test]
1128 fn platform_bin_plugin_is_valid_when_gate_narrows_to_covered_platforms() {
1129 let json = r#"{
1132 "id": "binbacked",
1133 "name": "Bin Backed",
1134 "version": "1.0.0",
1135 "platforms": ["macos", "windows"],
1136 "provides": {
1137 "mcp_servers": [
1138 {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1139 ]
1140 },
1141 "artifacts": {
1142 "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1143 "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1144 }
1145 }"#;
1146 let manifest = PluginManifest::parse_str(json).unwrap();
1147 manifest
1148 .validate()
1149 .expect("gate-narrowed binary plugin is valid");
1150 assert!(manifest.uses_platform_bin_token());
1151 }
1152
1153 #[test]
1154 fn rejects_artifact_for_platform_outside_the_gate() {
1155 let json = r#"{
1156 "id": "gated",
1157 "name": "Gated",
1158 "version": "1.0.0",
1159 "platforms": ["macos"],
1160 "artifacts": {
1161 "linux": {"url": "https://example.com/x-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
1162 }
1163 }"#;
1164 let manifest = PluginManifest::parse_str(json).unwrap();
1165 let error = manifest
1166 .validate()
1167 .expect_err("artifact outside gate should fail");
1168 assert!(error.to_string().contains("not in the `platforms` gate"));
1169 }
1170
1171 #[test]
1172 fn local_install_with_platform_bin_and_no_artifacts_is_valid() {
1173 let json = r#"{
1176 "id": "localbin",
1177 "name": "Local Bin",
1178 "version": "1.0.0",
1179 "provides": {
1180 "mcp_servers": [
1181 {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1182 ]
1183 }
1184 }"#;
1185 let manifest = PluginManifest::parse_str(json).unwrap();
1186 manifest
1187 .validate()
1188 .expect("local binary plugin without artifacts is valid");
1189 }
1190
1191 #[test]
1192 fn rejects_reserved_preset_id() {
1193 let json = r#"{
1194 "id": "reserver",
1195 "name": "Reserver",
1196 "version": "1.0.0",
1197 "provides": {
1198 "prompts": [
1199 {"id": "general_assistant", "name": "Nope", "content": "x"}
1200 ]
1201 }
1202 }"#;
1203 let manifest = PluginManifest::parse_str(json).unwrap();
1204 let error = manifest
1205 .validate()
1206 .expect_err("reserved preset id should fail");
1207 assert!(error.to_string().contains("prompt preset id"));
1208 assert!(!is_valid_preset_id("general_assistant"));
1209 }
1210
1211 #[test]
1212 fn rejects_unknown_artifact_platform_key() {
1213 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1214 manifest.artifacts.insert(
1215 "solaris".to_string(),
1216 PluginArtifact {
1217 url: "https://example.com/x.tar.gz".to_string(),
1218 sha256: "a".repeat(64),
1219 },
1220 );
1221 let error = manifest
1222 .validate()
1223 .expect_err("unknown platform key should fail");
1224 assert!(error.to_string().contains("unknown platform key"));
1225 }
1226
1227 #[test]
1228 fn semver_shape_check() {
1229 assert!(is_plausible_semver("1.2.3"));
1230 assert!(is_plausible_semver("1.2.3-beta.1"));
1231 assert!(is_plausible_semver("1.2.3+build.7"));
1232 assert!(is_plausible_semver("1.2.3-beta+build"));
1233 assert!(!is_plausible_semver("1.2"));
1234 assert!(!is_plausible_semver("latest"));
1235 assert!(!is_plausible_semver(""));
1236 assert!(!is_plausible_semver("v1.2.3"));
1237 }
1238
1239 fn service_manifest_json(id: &str, command: &str) -> String {
1240 serde_json::json!({
1241 "id": "svc-plugin",
1242 "name": "Svc Plugin",
1243 "version": "1.0.0",
1244 "provides": {
1245 "services": [
1246 {"id": id, "command": command}
1247 ]
1248 }
1249 })
1250 .to_string()
1251 }
1252
1253 #[test]
1254 fn parses_and_validates_minimal_service_entry() {
1255 let json = service_manifest_json("svc", PLATFORM_BIN_TOKEN);
1256 let manifest = PluginManifest::parse_str(&json).unwrap();
1257 manifest.validate().expect("minimal service entry is valid");
1258 let entry = &manifest.provides.services[0];
1259 assert!(entry.enabled);
1260 assert_eq!(entry.health_check.kind, HealthCheckKind::ProcessAlive);
1261 assert_eq!(entry.graceful_shutdown.signal, ShutdownSignal::Term);
1262 assert!(manifest.uses_platform_bin_token());
1263 }
1264
1265 #[test]
1266 fn rejects_service_command_that_is_not_exactly_the_platform_bin_token() {
1267 for bad_command in ["/usr/bin/env", "nova", "${platform_bin} --serve", ""] {
1268 let json = service_manifest_json("svc", bad_command);
1269 let manifest = PluginManifest::parse_str(&json).unwrap();
1270 let error = manifest
1271 .validate()
1272 .expect_err("non-token service command must be rejected");
1273 assert!(matches!(error, PluginError::InvalidManifest(_)));
1274 }
1275 }
1276
1277 #[test]
1278 fn rejects_duplicate_service_ids() {
1279 let json = serde_json::json!({
1280 "id": "svc-plugin",
1281 "name": "Svc",
1282 "version": "1.0.0",
1283 "provides": {
1284 "services": [
1285 {"id": "a", "command": PLATFORM_BIN_TOKEN},
1286 {"id": "a", "command": PLATFORM_BIN_TOKEN}
1287 ]
1288 }
1289 })
1290 .to_string();
1291 let manifest = PluginManifest::parse_str(&json).unwrap();
1292 let error = manifest
1293 .validate()
1294 .expect_err("duplicate service id should fail");
1295 assert!(error.to_string().contains("duplicate service id"));
1296 }
1297
1298 #[test]
1299 fn rejects_tcp_and_http_health_check_missing_target() {
1300 for kind in ["tcp", "http"] {
1301 let json = serde_json::json!({
1302 "id": "svc-plugin",
1303 "name": "Svc",
1304 "version": "1.0.0",
1305 "provides": {
1306 "services": [
1307 {"id": "a", "command": PLATFORM_BIN_TOKEN, "health_check": {"kind": kind}}
1308 ]
1309 }
1310 })
1311 .to_string();
1312 let manifest = PluginManifest::parse_str(&json).unwrap();
1313 let error = manifest
1314 .validate()
1315 .expect_err("tcp/http health_check without a target should fail");
1316 assert!(error.to_string().contains("target"));
1317 }
1318 }
1319
1320 #[test]
1321 fn accepts_tcp_health_check_with_target() {
1322 let json = serde_json::json!({
1323 "id": "svc-plugin",
1324 "name": "Svc",
1325 "version": "1.0.0",
1326 "provides": {
1327 "services": [
1328 {"id": "a", "command": PLATFORM_BIN_TOKEN, "health_check": {"kind": "tcp", "target": "127.0.0.1:9000"}}
1329 ]
1330 }
1331 })
1332 .to_string();
1333 let manifest = PluginManifest::parse_str(&json).unwrap();
1334 manifest
1335 .validate()
1336 .expect("tcp health_check with target is valid");
1337 }
1338
1339 #[test]
1340 fn services_missing_artifact_for_a_supported_platform_is_rejected() {
1341 let json = serde_json::json!({
1345 "id": "svc-plugin",
1346 "name": "Svc",
1347 "version": "1.0.0",
1348 "provides": {
1349 "services": [
1350 {"id": "a", "command": PLATFORM_BIN_TOKEN}
1351 ]
1352 },
1353 "artifacts": {
1354 "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1355 "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1356 }
1357 })
1358 .to_string();
1359 let manifest = PluginManifest::parse_str(&json).unwrap();
1360 let error = manifest
1361 .validate()
1362 .expect_err("missing linux artifact for a service-only plugin should fail");
1363 assert!(error.to_string().contains("linux"));
1364 }
1365
1366 #[test]
1367 fn resolve_service_entry_substitutes_tokens_and_pins_command_to_platform_bin() {
1368 let json = serde_json::json!({
1369 "id": "svc-plugin",
1370 "name": "Svc",
1371 "version": "1.0.0",
1372 "provides": {
1373 "services": [
1374 {
1375 "id": "a",
1376 "command": PLATFORM_BIN_TOKEN,
1377 "args": ["--config", "${plugin_dir}/data"],
1378 "cwd": "${plugin_dir}",
1379 "env": {"HOME_DIR": "${plugin_dir}/home"}
1380 }
1381 ]
1382 }
1383 })
1384 .to_string();
1385 let manifest = PluginManifest::parse_str(&json).unwrap();
1386 manifest.validate().expect("valid");
1387 let entry = &manifest.provides.services[0];
1388 let plugin_dir = Path::new("/home/user/.bamboo/plugins/svc-plugin");
1389 let resolved = entry.resolve(plugin_dir, &manifest.id, Platform::Linux);
1390 assert_eq!(
1391 resolved.command,
1392 PathBuf::from("/home/user/.bamboo/plugins/svc-plugin/bin/linux/svc-plugin")
1393 );
1394 assert_eq!(
1395 resolved.args,
1396 vec![
1397 "--config".to_string(),
1398 "/home/user/.bamboo/plugins/svc-plugin/data".to_string()
1399 ]
1400 );
1401 assert_eq!(resolved.cwd, Some(plugin_dir.to_path_buf()));
1402 assert_eq!(
1403 resolved.env.get("HOME_DIR").map(String::as_str),
1404 Some("/home/user/.bamboo/plugins/svc-plugin/home")
1405 );
1406 }
1407
1408 #[test]
1409 fn plugin_id_rules() {
1410 assert!(is_valid_plugin_id("hello-plugin"));
1411 assert!(is_valid_plugin_id("nova_plugin_2"));
1412 assert!(!is_valid_plugin_id(""));
1413 assert!(!is_valid_plugin_id("Hello"));
1414 assert!(!is_valid_plugin_id("hello plugin"));
1415 assert!(!is_valid_plugin_id(&"a".repeat(MAX_PLUGIN_ID_LEN + 1)));
1416 }
1417}