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