1use std::collections::{BTreeMap, HashMap};
56use std::path::{Path, PathBuf};
57
58use serde::{Deserialize, Serialize};
59
60use bamboo_plugin_protocol::{
61 ToolEventSubscriptionId, FILE_CHANGED_SUBSCRIPTION_ID_V1, MAX_TOOL_EVENT_JSON_BYTES,
62 MAX_TOOL_EVENT_SUBSCRIPTION_ID_BYTES, MAX_TOOL_EVENT_TOOL_NAME_BYTES, TOOL_EVENT_PROTOCOL_NAME,
63 TOOL_EVENT_V1_SCHEMA_VERSION,
64};
65
66use crate::error::{PluginError, PluginResult};
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum Platform {
76 Macos,
77 Windows,
78 Linux,
79}
80
81impl Platform {
82 pub fn current() -> Option<Platform> {
86 Self::parse(std::env::consts::OS)
87 }
88
89 pub fn as_str(self) -> &'static str {
90 match self {
91 Platform::Macos => "macos",
92 Platform::Windows => "windows",
93 Platform::Linux => "linux",
94 }
95 }
96
97 pub fn parse(value: &str) -> Option<Platform> {
100 match value {
101 "macos" => Some(Platform::Macos),
102 "windows" => Some(Platform::Windows),
103 "linux" => Some(Platform::Linux),
104 _ => None,
105 }
106 }
107}
108
109impl std::fmt::Display for Platform {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 f.write_str(self.as_str())
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct McpServerManifestEntry {
121 pub id: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub name: Option<String>,
125 #[serde(default = "default_true")]
126 pub enabled: bool,
127 pub transport: McpTransportManifest,
128 #[serde(default)]
129 pub allowed_tools: Vec<String>,
130 #[serde(default)]
131 pub denied_tools: Vec<String>,
132}
133
134fn default_true() -> bool {
135 true
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(tag = "type", rename_all = "snake_case")]
145pub enum McpTransportManifest {
146 Stdio {
147 command: String,
149 #[serde(default)]
150 args: Vec<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 cwd: Option<String>,
154 #[serde(default)]
156 env: HashMap<String, String>,
157 },
158 Sse {
159 url: String,
160 #[serde(default)]
161 headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
162 },
163 #[serde(rename = "streamable_http")]
164 StreamableHttp {
165 url: String,
166 #[serde(default)]
167 headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
168 },
169}
170
171impl McpServerManifestEntry {
172 pub fn resolve(
180 &self,
181 plugin_dir: &Path,
182 plugin_id: &str,
183 platform: Platform,
184 ) -> PluginResult<bamboo_domain::mcp_config::McpServerConfig> {
185 use bamboo_domain::mcp_config::{
186 default_connect_timeout, default_healthcheck_interval, default_request_timeout,
187 default_startup_timeout, McpServerConfig, ReconnectConfig, SseConfig, StdioConfig,
188 StreamableHttpConfig, TransportConfig,
189 };
190
191 let transport = match &self.transport {
192 McpTransportManifest::Stdio {
193 command,
194 args,
195 cwd,
196 env,
197 } => {
198 if command.trim().is_empty() {
199 return Err(PluginError::InvalidManifest(format!(
200 "mcp server '{}' has an empty stdio command",
201 self.id
202 )));
203 }
204 TransportConfig::Stdio(StdioConfig {
205 command: substitute_tokens(command, plugin_dir, plugin_id, platform),
206 args: args
207 .iter()
208 .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform))
209 .collect(),
210 cwd: cwd
211 .as_deref()
212 .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform)),
213 env: env
214 .iter()
215 .map(|(key, value)| {
216 (
217 key.clone(),
218 substitute_tokens(value, plugin_dir, plugin_id, platform),
219 )
220 })
221 .collect(),
222 env_encrypted: HashMap::new(),
223 env_credential_refs: std::collections::HashMap::new(),
224 startup_timeout_ms: default_startup_timeout(),
225 })
226 }
227 McpTransportManifest::Sse { url, headers } => TransportConfig::Sse(SseConfig {
228 url: url.clone(),
229 headers: headers.clone(),
230 connect_timeout_ms: default_connect_timeout(),
231 }),
232 McpTransportManifest::StreamableHttp { url, headers } => {
233 TransportConfig::StreamableHttp(StreamableHttpConfig {
234 url: url.clone(),
235 headers: headers.clone(),
236 connect_timeout_ms: default_connect_timeout(),
237 })
238 }
239 };
240
241 Ok(McpServerConfig {
242 id: self.id.clone(),
243 name: self.name.clone(),
244 enabled: self.enabled,
245 transport,
246 request_timeout_ms: default_request_timeout(),
247 healthcheck_interval_ms: default_healthcheck_interval(),
248 reconnect: ReconnectConfig::default(),
249 allowed_tools: self.allowed_tools.clone(),
250 denied_tools: self.denied_tools.clone(),
251 })
252 }
253}
254
255pub fn substitute_tokens(
260 template: &str,
261 plugin_dir: &Path,
262 plugin_id: &str,
263 platform: Platform,
264) -> String {
265 let plugin_dir_str = plugin_dir.to_string_lossy();
266 let platform_bin_str = platform_bin_path(plugin_dir, plugin_id, platform)
267 .to_string_lossy()
268 .into_owned();
269 template
270 .replace("${plugin_dir}", plugin_dir_str.as_ref())
271 .replace("${platform_bin}", &platform_bin_str)
272}
273
274pub fn platform_bin_path(plugin_dir: &Path, plugin_id: &str, platform: Platform) -> PathBuf {
277 let filename = if matches!(platform, Platform::Windows) {
278 format!("{plugin_id}.exe")
279 } else {
280 plugin_id.to_string()
281 };
282 plugin_dir
283 .join("bin")
284 .join(platform.as_str())
285 .join(filename)
286}
287
288pub const PLATFORM_BIN_TOKEN: &str = "${platform_bin}";
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "snake_case")]
300pub enum HealthCheckKind {
301 ProcessAlive,
302 Tcp,
303 Http,
304}
305
306fn default_health_interval_ms() -> u64 {
307 15_000
308}
309
310fn default_health_timeout_ms() -> u64 {
311 5_000
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct HealthCheckSpec {
317 pub kind: HealthCheckKind,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub target: Option<String>,
322 #[serde(default = "default_health_interval_ms")]
323 pub interval_ms: u64,
324 #[serde(default = "default_health_timeout_ms")]
325 pub timeout_ms: u64,
326}
327
328impl Default for HealthCheckSpec {
329 fn default() -> Self {
330 Self {
331 kind: HealthCheckKind::ProcessAlive,
332 target: None,
333 interval_ms: default_health_interval_ms(),
334 timeout_ms: default_health_timeout_ms(),
335 }
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
344#[serde(rename_all = "snake_case")]
345pub enum ShutdownSignal {
346 #[default]
347 Term,
348 None,
349}
350
351fn default_shutdown_timeout_ms() -> u64 {
352 5_000
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct GracefulShutdown {
358 #[serde(default)]
359 pub signal: ShutdownSignal,
360 #[serde(default = "default_shutdown_timeout_ms")]
363 pub timeout_ms: u64,
364}
365
366impl Default for GracefulShutdown {
367 fn default() -> Self {
368 Self {
369 signal: ShutdownSignal::default(),
370 timeout_ms: default_shutdown_timeout_ms(),
371 }
372 }
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum ServiceInputProtocol {
384 #[default]
385 None,
386 NdjsonV1,
387}
388
389impl ServiceInputProtocol {
390 fn is_none(&self) -> bool {
391 matches!(self, Self::None)
392 }
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct ServiceManifestEntry {
410 pub id: String,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub name: Option<String>,
415 #[serde(default = "default_true")]
416 pub enabled: bool,
417 pub command: String,
419 #[serde(default)]
420 pub args: Vec<String>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub cwd: Option<String>,
424 #[serde(default)]
426 pub env: HashMap<String, String>,
427 #[serde(default)]
428 pub health_check: HealthCheckSpec,
429 #[serde(default)]
433 pub restart_policy: bamboo_domain::mcp_config::ReconnectConfig,
434 #[serde(default)]
435 pub graceful_shutdown: GracefulShutdown,
436 #[serde(default, skip_serializing_if = "ServiceInputProtocol::is_none")]
439 pub input_protocol: ServiceInputProtocol,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448pub struct EventSinkProtocolManifest {
449 pub name: String,
450 pub version: u16,
451 #[serde(default, flatten)]
455 pub extensions: BTreeMap<String, serde_json::Value>,
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
461pub struct EventSinkDeliveryLimits {
462 #[serde(default = "default_event_sink_queue_capacity")]
463 pub queue_capacity: u32,
464 #[serde(default = "default_event_sink_max_event_bytes")]
465 pub max_event_bytes: u32,
466 #[serde(default, flatten)]
467 pub extensions: BTreeMap<String, serde_json::Value>,
468}
469
470pub const DEFAULT_EVENT_SINK_QUEUE_CAPACITY: u32 = 64;
471pub const MAX_EVENT_SINK_QUEUE_CAPACITY: u32 = 1024;
472pub const MAX_EVENT_SINK_EVENT_BYTES: u32 = 1024 * 1024;
473pub const MAX_EVENT_SINK_MANIFEST_BUFFER_BYTES: u64 = 64 * 1024 * 1024;
474pub const MAX_EVENT_SINKS_PER_PLUGIN: usize = 64;
475pub const MAX_EVENT_SINK_ID_BYTES: usize = 128;
476pub const MAX_EVENT_SINK_SERVICE_ID_BYTES: usize = 128;
477pub const MAX_EVENT_SINK_SUBSCRIPTIONS: usize = 32;
478pub const MAX_EVENT_SINK_TOOL_NAMES: usize = 64;
479pub const MAX_EVENT_SINK_PERMISSIONS: usize = 32;
480pub const MAX_EVENT_SINK_PERMISSION_ID_BYTES: usize = 64;
481pub const MAX_EVENT_SINK_EXTENSION_FIELDS: usize = 16;
482pub const MAX_EVENT_SINK_EXTENSION_KEY_BYTES: usize = 64;
483pub const MAX_EVENT_SINK_EXTENSION_VALUE_BYTES: usize = 4096;
484
485fn default_event_sink_queue_capacity() -> u32 {
486 DEFAULT_EVENT_SINK_QUEUE_CAPACITY
487}
488
489fn default_event_sink_max_event_bytes() -> u32 {
490 MAX_TOOL_EVENT_JSON_BYTES as u32
491}
492
493impl Default for EventSinkDeliveryLimits {
494 fn default() -> Self {
495 Self {
496 queue_capacity: default_event_sink_queue_capacity(),
497 max_event_bytes: default_event_sink_max_event_bytes(),
498 extensions: BTreeMap::new(),
499 }
500 }
501}
502
503fn validate_event_sink_extensions(
504 sink_id: &str,
505 scope: &str,
506 extensions: &BTreeMap<String, serde_json::Value>,
507 strict_v1: bool,
508) -> PluginResult<()> {
509 if strict_v1 && !extensions.is_empty() {
510 return Err(PluginError::InvalidManifest(format!(
511 "event sink '{sink_id}' ToolEventV1 {scope} contains unknown field(s): {}",
512 extensions.keys().cloned().collect::<Vec<_>>().join(", ")
513 )));
514 }
515 if extensions.len() > MAX_EVENT_SINK_EXTENSION_FIELDS {
516 return Err(PluginError::InvalidManifest(format!(
517 "event sink '{sink_id}' {scope} exceeds the extension-field limit of {MAX_EVENT_SINK_EXTENSION_FIELDS}"
518 )));
519 }
520 for (key, value) in extensions {
521 if key.trim().is_empty() || key.len() > MAX_EVENT_SINK_EXTENSION_KEY_BYTES {
522 return Err(PluginError::InvalidManifest(format!(
523 "event sink '{sink_id}' {scope} contains an invalid extension key"
524 )));
525 }
526 let value_len = serde_json::to_vec(value)
527 .map_err(|error| {
528 PluginError::InvalidManifest(format!(
529 "event sink '{sink_id}' {scope} extension '{key}' cannot be serialized: {error}"
530 ))
531 })?
532 .len();
533 if value_len > MAX_EVENT_SINK_EXTENSION_VALUE_BYTES {
534 return Err(PluginError::InvalidManifest(format!(
535 "event sink '{sink_id}' {scope} extension '{key}' exceeds the value-size limit of {MAX_EVENT_SINK_EXTENSION_VALUE_BYTES} bytes"
536 )));
537 }
538 }
539 Ok(())
540}
541
542pub const OBSERVE_METADATA_PERMISSION: &str = "metadata";
545pub const OBSERVE_TOOL_NAME_PERMISSION: &str = "tool_name";
546pub const OBSERVE_PATHS_PERMISSION: &str = "paths";
547pub const OBSERVE_DIFF_PERMISSION: &str = "diff";
548pub const OBSERVE_CONTENT_PERMISSION: &str = "content";
549
550#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
555#[serde(transparent)]
556pub struct ObservationPermissionId(String);
557
558impl ObservationPermissionId {
559 pub fn new(value: impl Into<String>) -> Self {
560 Self(value.into())
561 }
562
563 pub fn as_str(&self) -> &str {
564 &self.0
565 }
566}
567
568fn default_event_sink_permissions() -> Vec<ObservationPermissionId> {
569 vec![ObservationPermissionId::new(OBSERVE_METADATA_PERMISSION)]
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
576pub struct EventSinkSubscriptionManifest {
577 pub id: ToolEventSubscriptionId,
578 #[serde(default, skip_serializing_if = "Vec::is_empty")]
579 pub tool_names: Vec<String>,
580 #[serde(default, flatten)]
581 pub extensions: BTreeMap<String, serde_json::Value>,
582}
583
584#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
586pub struct EventSinkManifestEntry {
587 pub id: String,
588 pub service_id: String,
589 pub protocol: EventSinkProtocolManifest,
590 #[serde(default)]
591 pub subscriptions: Vec<EventSinkSubscriptionManifest>,
592 #[serde(default)]
593 pub delivery: EventSinkDeliveryLimits,
594 #[serde(default = "default_event_sink_permissions")]
595 pub requested_permissions: Vec<ObservationPermissionId>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub platforms: Option<Vec<Platform>>,
600 #[serde(default, flatten)]
601 pub extensions: BTreeMap<String, serde_json::Value>,
602}
603
604#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
605#[serde(tag = "reason", rename_all = "snake_case")]
606pub enum EventSinkInactiveReason {
607 UnsupportedProtocolVersion {
608 requested: u16,
609 supported: u16,
610 },
611 InstallIncomplete,
612 PlatformIneligible,
613 ServiceDisabled,
614 ObservationPermissionNotGranted {
618 permission: ObservationPermissionId,
619 },
620}
621
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
625#[serde(tag = "status", rename_all = "snake_case")]
626pub enum EventSinkCapabilityState {
627 Eligible,
628 Inactive { detail: EventSinkInactiveReason },
629}
630
631impl EventSinkManifestEntry {
632 pub fn capability_state(
633 &self,
634 service: &ServiceManifestEntry,
635 platform: Option<Platform>,
636 ) -> EventSinkCapabilityState {
637 if self.protocol.version > TOOL_EVENT_V1_SCHEMA_VERSION {
638 return EventSinkCapabilityState::Inactive {
639 detail: EventSinkInactiveReason::UnsupportedProtocolVersion {
640 requested: self.protocol.version,
641 supported: TOOL_EVENT_V1_SCHEMA_VERSION,
642 },
643 };
644 }
645 if platform.is_none()
646 || self.platforms.as_ref().is_some_and(|platforms| {
647 platform.is_some_and(|platform| !platforms.contains(&platform))
648 })
649 {
650 return EventSinkCapabilityState::Inactive {
651 detail: EventSinkInactiveReason::PlatformIneligible,
652 };
653 }
654 if !service.enabled {
655 return EventSinkCapabilityState::Inactive {
656 detail: EventSinkInactiveReason::ServiceDisabled,
657 };
658 }
659 EventSinkCapabilityState::Eligible
660 }
661}
662
663#[derive(Debug, Clone)]
668pub struct ResolvedServiceEntry {
669 pub id: String,
670 pub name: Option<String>,
671 pub enabled: bool,
672 pub command: PathBuf,
673 pub args: Vec<String>,
674 pub cwd: Option<PathBuf>,
675 pub env: HashMap<String, String>,
676 pub health_check: HealthCheckSpec,
677 pub restart_policy: bamboo_domain::mcp_config::ReconnectConfig,
678 pub graceful_shutdown: GracefulShutdown,
679 pub input_protocol: ServiceInputProtocol,
680}
681
682impl ServiceManifestEntry {
683 pub fn resolve(
690 &self,
691 plugin_dir: &Path,
692 plugin_id: &str,
693 platform: Platform,
694 ) -> ResolvedServiceEntry {
695 ResolvedServiceEntry {
696 id: self.id.clone(),
697 name: self.name.clone(),
698 enabled: self.enabled,
699 command: platform_bin_path(plugin_dir, plugin_id, platform),
700 args: self
701 .args
702 .iter()
703 .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform))
704 .collect(),
705 cwd: self.cwd.as_deref().map(|value| {
706 PathBuf::from(substitute_tokens(value, plugin_dir, plugin_id, platform))
707 }),
708 env: self
709 .env
710 .iter()
711 .map(|(key, value)| {
712 (
713 key.clone(),
714 substitute_tokens(value, plugin_dir, plugin_id, platform),
715 )
716 })
717 .collect(),
718 health_check: self.health_check.clone(),
719 restart_policy: self.restart_policy.clone(),
720 graceful_shutdown: self.graceful_shutdown.clone(),
721 input_protocol: self.input_protocol,
722 }
723 }
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct PluginPromptPreset {
733 pub id: String,
734 pub name: String,
735 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub description: Option<String>,
737 pub content: String,
738}
739
740#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct PluginArtifact {
761 pub url: String,
763 pub sha256: String,
766}
767
768#[derive(Debug, Clone, Default, Serialize, Deserialize)]
771pub struct PluginProvides {
772 #[serde(default, skip_serializing_if = "Vec::is_empty")]
773 pub mcp_servers: Vec<McpServerManifestEntry>,
774 #[serde(default, skip_serializing_if = "Vec::is_empty")]
780 pub skills: Vec<String>,
781 #[serde(default, skip_serializing_if = "Vec::is_empty")]
782 pub prompts: Vec<PluginPromptPreset>,
783 #[serde(default, skip_serializing_if = "Vec::is_empty")]
787 pub workflows: Vec<String>,
788 #[serde(default, skip_serializing_if = "Vec::is_empty")]
791 pub services: Vec<ServiceManifestEntry>,
792 #[serde(default, skip_serializing_if = "Vec::is_empty")]
794 pub event_sinks: Vec<EventSinkManifestEntry>,
795}
796
797impl PluginProvides {
798 pub fn is_empty(&self) -> bool {
799 self.mcp_servers.is_empty()
800 && self.skills.is_empty()
801 && self.prompts.is_empty()
802 && self.workflows.is_empty()
803 && self.services.is_empty()
804 && self.event_sinks.is_empty()
805 }
806}
807
808#[derive(Debug, Clone, Serialize, Deserialize)]
810pub struct PluginManifest {
811 pub id: String,
814 pub name: String,
815 pub version: String,
820 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub description: Option<String>,
822 #[serde(default, skip_serializing_if = "Option::is_none")]
824 pub bamboo_min_version: Option<String>,
825 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub platforms: Option<Vec<Platform>>,
830 #[serde(default)]
831 pub provides: PluginProvides,
832 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
838 pub artifacts: HashMap<String, PluginArtifact>,
839}
840
841const MAX_PLUGIN_ID_LEN: usize = 64;
842const MAX_PRESET_ID_LEN: usize = 80;
843
844const RESERVED_PRESET_IDS: &[&str] = &["general_assistant"];
852
853pub fn is_valid_plugin_id(id: &str) -> bool {
858 !id.is_empty()
859 && id.len() <= MAX_PLUGIN_ID_LEN
860 && id
861 .chars()
862 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
863}
864
865pub fn is_valid_preset_id(id: &str) -> bool {
871 !id.is_empty()
872 && id.len() <= MAX_PRESET_ID_LEN
873 && !RESERVED_PRESET_IDS.contains(&id)
874 && id
875 .chars()
876 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
877}
878
879pub fn is_plausible_semver(value: &str) -> bool {
885 let core = value.split(['-', '+']).next().unwrap_or_default();
886 let parts: Vec<&str> = core.split('.').collect();
887 parts.len() == 3
888 && parts
889 .iter()
890 .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
891}
892
893fn is_safe_relative_name(name: &str) -> bool {
897 !name.is_empty()
898 && !name.contains('/')
899 && !name.contains('\\')
900 && !name.contains("..")
901 && !name.chars().any(|ch| ch.is_control())
902}
903
904impl PluginManifest {
905 pub fn parse_str(content: &str) -> PluginResult<Self> {
910 serde_json::from_str(content).map_err(PluginError::from)
911 }
912
913 pub fn validate(&self) -> PluginResult<()> {
917 if !is_valid_plugin_id(&self.id) {
918 return Err(PluginError::InvalidManifest(format!(
919 "invalid plugin id '{}': must be [a-z0-9-_], <= {} chars",
920 self.id, MAX_PLUGIN_ID_LEN
921 )));
922 }
923 if self.name.trim().is_empty() {
924 return Err(PluginError::InvalidManifest(
925 "plugin name must not be empty".to_string(),
926 ));
927 }
928 if !is_plausible_semver(&self.version) {
929 return Err(PluginError::InvalidManifest(format!(
930 "invalid plugin version '{}': expected major.minor.patch[-pre][+build]",
931 self.version
932 )));
933 }
934 if let Some(min_version) = &self.bamboo_min_version {
935 if !is_plausible_semver(min_version) {
936 return Err(PluginError::InvalidManifest(format!(
937 "invalid bamboo_min_version '{min_version}'"
938 )));
939 }
940 }
941 if let Some(platforms) = &self.platforms {
942 if platforms.is_empty() {
943 return Err(PluginError::InvalidManifest(
944 "platforms, if present, must not be empty (use `null`/omit for \"all platforms\")"
945 .to_string(),
946 ));
947 }
948 }
949
950 let mut seen_mcp_ids = std::collections::HashSet::new();
951 for entry in &self.provides.mcp_servers {
952 if entry.id.trim().is_empty() {
953 return Err(PluginError::InvalidManifest(
954 "mcp server entries must have a non-empty id".to_string(),
955 ));
956 }
957 if !seen_mcp_ids.insert(entry.id.clone()) {
958 return Err(PluginError::InvalidManifest(format!(
959 "duplicate mcp server id '{}' in provides.mcp_servers",
960 entry.id
961 )));
962 }
963 if let McpTransportManifest::Stdio { command, .. } = &entry.transport {
964 if command.trim().is_empty() {
965 return Err(PluginError::InvalidManifest(format!(
966 "mcp server '{}' has an empty stdio command",
967 entry.id
968 )));
969 }
970 }
971 }
972
973 let mut seen_service_ids = std::collections::HashSet::new();
974 for entry in &self.provides.services {
975 if entry.id.trim().is_empty() {
976 return Err(PluginError::InvalidManifest(
977 "service entries must have a non-empty id".to_string(),
978 ));
979 }
980 if !seen_service_ids.insert(entry.id.clone()) {
981 return Err(PluginError::InvalidManifest(format!(
982 "duplicate service id '{}' in provides.services",
983 entry.id
984 )));
985 }
986 if entry.command.trim().is_empty() {
987 return Err(PluginError::InvalidManifest(format!(
988 "service '{}' has an empty command",
989 entry.id
990 )));
991 }
992 if entry.command != PLATFORM_BIN_TOKEN {
997 return Err(PluginError::InvalidManifest(format!(
998 "service '{}' command must be exactly '{PLATFORM_BIN_TOKEN}' — services may \
999 only execute the plugin's own verified per-platform binary, never an \
1000 arbitrary command",
1001 entry.id
1002 )));
1003 }
1004 match entry.health_check.kind {
1005 HealthCheckKind::Tcp | HealthCheckKind::Http => {
1006 let target_ok = entry
1007 .health_check
1008 .target
1009 .as_deref()
1010 .map(|value| !value.trim().is_empty())
1011 .unwrap_or(false);
1012 if !target_ok {
1013 return Err(PluginError::InvalidManifest(format!(
1014 "service '{}' health_check.kind={:?} requires a non-empty target",
1015 entry.id, entry.health_check.kind
1016 )));
1017 }
1018 }
1019 HealthCheckKind::ProcessAlive => {}
1020 }
1021 }
1022
1023 let plugin_platforms = self.effective_platforms();
1024 if self.provides.event_sinks.len() > MAX_EVENT_SINKS_PER_PLUGIN {
1025 return Err(PluginError::InvalidManifest(format!(
1026 "provides.event_sinks exceeds the per-plugin limit of {MAX_EVENT_SINKS_PER_PLUGIN}"
1027 )));
1028 }
1029 let mut seen_sink_ids = std::collections::HashSet::new();
1030 let mut declared_buffer_bytes = 0_u64;
1031 for sink in &self.provides.event_sinks {
1032 if sink.id.trim().is_empty() || sink.id.len() > MAX_EVENT_SINK_ID_BYTES {
1033 return Err(PluginError::InvalidManifest(format!(
1034 "event sink id '{}' must be non-empty and no more than {} UTF-8 bytes",
1035 sink.id, MAX_EVENT_SINK_ID_BYTES
1036 )));
1037 }
1038 if !seen_sink_ids.insert(sink.id.as_str()) {
1039 return Err(PluginError::InvalidManifest(format!(
1040 "duplicate event sink id '{}' in provides.event_sinks",
1041 sink.id
1042 )));
1043 }
1044 if sink.service_id.trim().is_empty()
1045 || sink.service_id.len() > MAX_EVENT_SINK_SERVICE_ID_BYTES
1046 {
1047 return Err(PluginError::InvalidManifest(format!(
1048 "event sink '{}' must reference a non-empty service id no longer than {} UTF-8 bytes",
1049 sink.id, MAX_EVENT_SINK_SERVICE_ID_BYTES
1050 )));
1051 }
1052 let Some(service) = self
1053 .provides
1054 .services
1055 .iter()
1056 .find(|service| service.id == sink.service_id)
1057 else {
1058 return Err(PluginError::InvalidManifest(format!(
1059 "event sink '{}' references service '{}' which is not declared by the same plugin",
1060 sink.id, sink.service_id
1061 )));
1062 };
1063 if sink.protocol.name != TOOL_EVENT_PROTOCOL_NAME {
1064 return Err(PluginError::InvalidManifest(format!(
1065 "event sink '{}' uses unknown protocol family '{}' (expected '{}')",
1066 sink.id, sink.protocol.name, TOOL_EVENT_PROTOCOL_NAME
1067 )));
1068 }
1069 if sink.protocol.version == 0 {
1070 return Err(PluginError::InvalidManifest(format!(
1071 "event sink '{}' protocol version must be non-zero",
1072 sink.id
1073 )));
1074 }
1075 let strict_v1 = sink.protocol.version == TOOL_EVENT_V1_SCHEMA_VERSION;
1076 if strict_v1 && service.input_protocol != ServiceInputProtocol::NdjsonV1 {
1077 return Err(PluginError::InvalidManifest(format!(
1078 "ToolEventV1 event sink '{}' requires service '{}' to declare input_protocol 'ndjson_v1'",
1079 sink.id, sink.service_id
1080 )));
1081 }
1082 validate_event_sink_extensions(&sink.id, "declaration", &sink.extensions, strict_v1)?;
1083 validate_event_sink_extensions(
1084 &sink.id,
1085 "protocol",
1086 &sink.protocol.extensions,
1087 strict_v1,
1088 )?;
1089 validate_event_sink_extensions(
1090 &sink.id,
1091 "delivery",
1092 &sink.delivery.extensions,
1093 strict_v1,
1094 )?;
1095 if sink.subscriptions.is_empty()
1096 || sink.subscriptions.len() > MAX_EVENT_SINK_SUBSCRIPTIONS
1097 {
1098 return Err(PluginError::InvalidManifest(format!(
1099 "event sink '{}' must request 1..={MAX_EVENT_SINK_SUBSCRIPTIONS} subscriptions",
1100 sink.id,
1101 )));
1102 }
1103 let mut seen_subscriptions = std::collections::HashSet::new();
1104 for subscription in &sink.subscriptions {
1105 validate_event_sink_extensions(
1106 &sink.id,
1107 "subscription",
1108 &subscription.extensions,
1109 strict_v1,
1110 )?;
1111 let subscription_id = subscription.id.as_str();
1112 if subscription_id.trim().is_empty()
1113 || subscription_id.len() > MAX_TOOL_EVENT_SUBSCRIPTION_ID_BYTES
1114 {
1115 return Err(PluginError::InvalidManifest(format!(
1116 "event sink '{}' has an invalid subscription id",
1117 sink.id
1118 )));
1119 }
1120 if !seen_subscriptions.insert(subscription_id) {
1121 return Err(PluginError::InvalidManifest(format!(
1122 "event sink '{}' repeats subscription '{}'",
1123 sink.id, subscription_id
1124 )));
1125 }
1126 if subscription.tool_names.len() > MAX_EVENT_SINK_TOOL_NAMES {
1127 return Err(PluginError::InvalidManifest(format!(
1128 "event sink '{}' subscription '{}' exceeds the tool-name limit of {MAX_EVENT_SINK_TOOL_NAMES}",
1129 sink.id, subscription_id
1130 )));
1131 }
1132 let mut seen_tool_names = std::collections::HashSet::new();
1133 for tool_name in &subscription.tool_names {
1134 if tool_name.trim().is_empty()
1135 || tool_name.len() > MAX_TOOL_EVENT_TOOL_NAME_BYTES
1136 {
1137 return Err(PluginError::InvalidManifest(format!(
1138 "event sink '{}' subscription '{}' has an invalid tool name",
1139 sink.id, subscription_id
1140 )));
1141 }
1142 if !seen_tool_names.insert(tool_name.as_str()) {
1143 return Err(PluginError::InvalidManifest(format!(
1144 "event sink '{}' subscription '{}' repeats tool name '{}'",
1145 sink.id, subscription_id, tool_name
1146 )));
1147 }
1148 }
1149 }
1150 if sink.requested_permissions.is_empty()
1151 || sink.requested_permissions.len() > MAX_EVENT_SINK_PERMISSIONS
1152 {
1153 return Err(PluginError::InvalidManifest(format!(
1154 "event sink '{}' must request 1..={MAX_EVENT_SINK_PERMISSIONS} observation permissions",
1155 sink.id,
1156 )));
1157 }
1158 let mut seen_permissions = std::collections::HashSet::new();
1159 for permission in &sink.requested_permissions {
1160 let permission_id = permission.as_str();
1161 if permission_id.trim().is_empty()
1162 || permission_id.len() > MAX_EVENT_SINK_PERMISSION_ID_BYTES
1163 {
1164 return Err(PluginError::InvalidManifest(format!(
1165 "event sink '{}' has an invalid observation permission",
1166 sink.id
1167 )));
1168 }
1169 if !seen_permissions.insert(permission_id) {
1170 return Err(PluginError::InvalidManifest(format!(
1171 "event sink '{}' repeats observation permission '{}'",
1172 sink.id, permission_id
1173 )));
1174 }
1175 }
1176 if sink.delivery.queue_capacity == 0
1177 || sink.delivery.queue_capacity > MAX_EVENT_SINK_QUEUE_CAPACITY
1178 || sink.delivery.max_event_bytes == 0
1179 || sink.delivery.max_event_bytes > MAX_EVENT_SINK_EVENT_BYTES
1180 {
1181 return Err(PluginError::InvalidManifest(format!(
1182 "event sink '{}' delivery limits exceed absolute host bounds",
1183 sink.id
1184 )));
1185 }
1186 let sink_buffer_bytes = u64::from(sink.delivery.queue_capacity)
1187 .checked_mul(u64::from(sink.delivery.max_event_bytes))
1188 .ok_or_else(|| {
1189 PluginError::InvalidManifest(format!(
1190 "event sink '{}' delivery buffer size overflows",
1191 sink.id
1192 ))
1193 })?;
1194 declared_buffer_bytes = declared_buffer_bytes
1195 .checked_add(sink_buffer_bytes)
1196 .ok_or_else(|| {
1197 PluginError::InvalidManifest(
1198 "event sink aggregate delivery buffer size overflows".to_string(),
1199 )
1200 })?;
1201 if declared_buffer_bytes > MAX_EVENT_SINK_MANIFEST_BUFFER_BYTES {
1202 return Err(PluginError::InvalidManifest(format!(
1203 "provides.event_sinks requests more than {MAX_EVENT_SINK_MANIFEST_BUFFER_BYTES} bytes of aggregate delivery buffering"
1204 )));
1205 }
1206 if let Some(platforms) = &sink.platforms {
1207 if platforms.is_empty() {
1208 return Err(PluginError::InvalidManifest(format!(
1209 "event sink '{}' platforms, if present, must not be empty",
1210 sink.id
1211 )));
1212 }
1213 let mut seen_platforms = Vec::new();
1214 for platform in platforms {
1215 if seen_platforms.contains(platform) {
1216 return Err(PluginError::InvalidManifest(format!(
1217 "event sink '{}' repeats platform '{}'",
1218 sink.id, platform
1219 )));
1220 }
1221 seen_platforms.push(*platform);
1222 if !plugin_platforms.contains(platform) {
1223 return Err(PluginError::InvalidManifest(format!(
1224 "event sink '{}' platform gate must be a subset of the plugin platform gate",
1225 sink.id
1226 )));
1227 }
1228 }
1229 }
1230
1231 if sink.protocol.version == TOOL_EVENT_V1_SCHEMA_VERSION {
1235 if sink
1236 .subscriptions
1237 .iter()
1238 .any(|subscription| subscription.id.as_str() != FILE_CHANGED_SUBSCRIPTION_ID_V1)
1239 {
1240 return Err(PluginError::InvalidManifest(format!(
1241 "event sink '{}' requests an unsupported ToolEventV1 subscription",
1242 sink.id
1243 )));
1244 }
1245 const V1_PERMISSIONS: &[&str] = &[
1246 OBSERVE_METADATA_PERMISSION,
1247 OBSERVE_TOOL_NAME_PERMISSION,
1248 OBSERVE_PATHS_PERMISSION,
1249 OBSERVE_DIFF_PERMISSION,
1250 OBSERVE_CONTENT_PERMISSION,
1251 ];
1252 if sink
1253 .requested_permissions
1254 .iter()
1255 .any(|permission| !V1_PERMISSIONS.contains(&permission.as_str()))
1256 {
1257 return Err(PluginError::InvalidManifest(format!(
1258 "event sink '{}' requests an unsupported ToolEventV1 observation permission",
1259 sink.id
1260 )));
1261 }
1262 if !seen_permissions.contains(OBSERVE_METADATA_PERMISSION) {
1263 return Err(PluginError::InvalidManifest(format!(
1264 "event sink '{}' ToolEventV1 permissions must include '{}'",
1265 sink.id, OBSERVE_METADATA_PERMISSION
1266 )));
1267 }
1268 let requests_payload = seen_permissions.contains(OBSERVE_DIFF_PERMISSION)
1269 || seen_permissions.contains(OBSERVE_CONTENT_PERMISSION);
1270 if requests_payload && !seen_permissions.contains(OBSERVE_PATHS_PERMISSION) {
1271 return Err(PluginError::InvalidManifest(format!(
1272 "event sink '{}' requests diff/content without the required paths permission",
1273 sink.id
1274 )));
1275 }
1276 if sink.delivery.max_event_bytes > MAX_TOOL_EVENT_JSON_BYTES as u32 {
1277 return Err(PluginError::InvalidManifest(format!(
1278 "event sink '{}' ToolEventV1 delivery limits exceed host bounds",
1279 sink.id
1280 )));
1281 }
1282 }
1283 }
1284
1285 for skill_dir in &self.provides.skills {
1286 if !is_safe_relative_name(skill_dir) {
1287 return Err(PluginError::InvalidManifest(format!(
1288 "invalid skill directory name '{skill_dir}' in provides.skills"
1289 )));
1290 }
1291 }
1292
1293 let mut seen_preset_ids = std::collections::HashSet::new();
1294 for preset in &self.provides.prompts {
1295 if !is_valid_preset_id(&preset.id) {
1296 return Err(PluginError::InvalidManifest(format!(
1297 "invalid prompt preset id '{}': must be [a-z0-9_], <= {} chars",
1298 preset.id, MAX_PRESET_ID_LEN
1299 )));
1300 }
1301 if !seen_preset_ids.insert(preset.id.clone()) {
1302 return Err(PluginError::InvalidManifest(format!(
1303 "duplicate prompt preset id '{}' in provides.prompts",
1304 preset.id
1305 )));
1306 }
1307 if preset.name.trim().is_empty() {
1308 return Err(PluginError::InvalidManifest(format!(
1309 "prompt preset '{}' has an empty name",
1310 preset.id
1311 )));
1312 }
1313 if preset.content.trim().is_empty() {
1314 return Err(PluginError::InvalidManifest(format!(
1315 "prompt preset '{}' has empty content",
1316 preset.id
1317 )));
1318 }
1319 }
1320
1321 for workflow_file in &self.provides.workflows {
1322 if !is_safe_relative_name(workflow_file) || !workflow_file.ends_with(".md") {
1323 return Err(PluginError::InvalidManifest(format!(
1324 "invalid workflow filename '{workflow_file}' in provides.workflows (must be a bare '<name>.md')"
1325 )));
1326 }
1327 }
1328
1329 for (platform_key, artifact) in &self.artifacts {
1330 let Some(artifact_platform) = Platform::parse(platform_key) else {
1331 return Err(PluginError::InvalidManifest(format!(
1332 "unknown platform key '{platform_key}' in artifacts (expected macos/windows/linux)"
1333 )));
1334 };
1335 if let Some(gate) = &self.platforms {
1339 if !gate.contains(&artifact_platform) {
1340 return Err(PluginError::InvalidManifest(format!(
1341 "artifacts contains platform '{platform_key}' which is not in the \
1342 `platforms` gate {:?}",
1343 gate.iter()
1344 .map(|platform| platform.as_str())
1345 .collect::<Vec<_>>()
1346 )));
1347 }
1348 }
1349 if artifact.url.trim().is_empty() {
1350 return Err(PluginError::InvalidManifest(format!(
1351 "artifact for platform '{platform_key}' has an empty url"
1352 )));
1353 }
1354 let sha_is_hex64 = artifact.sha256.len() == 64
1355 && artifact.sha256.chars().all(|ch| ch.is_ascii_hexdigit());
1356 if !sha_is_hex64 {
1357 return Err(PluginError::InvalidManifest(format!(
1358 "artifact for platform '{platform_key}' has an invalid sha256 (expected 64 lowercase hex chars)"
1359 )));
1360 }
1361 }
1362
1363 if !self.artifacts.is_empty() && self.uses_platform_bin_token() {
1372 for platform in self.effective_platforms() {
1373 if !self.artifacts.contains_key(platform.as_str()) {
1374 return Err(PluginError::InvalidManifest(format!(
1375 "plugin uses ${{platform_bin}} and ships URL artifacts, but has no \
1376 artifact for supported platform '{}' (every supported platform needs a \
1377 downloadable binary bundle)",
1378 platform.as_str()
1379 )));
1380 }
1381 }
1382 }
1383
1384 Ok(())
1385 }
1386
1387 pub fn supports_platform(&self, platform: Platform) -> bool {
1390 match &self.platforms {
1391 None => true,
1392 Some(platforms) => platforms.contains(&platform),
1393 }
1394 }
1395
1396 pub fn effective_platforms(&self) -> Vec<Platform> {
1400 self.platforms
1401 .clone()
1402 .unwrap_or_else(|| vec![Platform::Macos, Platform::Windows, Platform::Linux])
1403 }
1404
1405 pub fn uses_platform_bin_token(&self) -> bool {
1410 const TOKEN: &str = PLATFORM_BIN_TOKEN;
1411 let mcp_uses = self.provides.mcp_servers.iter().any(|entry| {
1412 let McpTransportManifest::Stdio {
1413 command,
1414 args,
1415 cwd,
1416 env,
1417 } = &entry.transport
1418 else {
1419 return false;
1420 };
1421 command.contains(TOKEN)
1422 || args.iter().any(|value| value.contains(TOKEN))
1423 || cwd.as_deref().is_some_and(|value| value.contains(TOKEN))
1424 || env.values().any(|value| value.contains(TOKEN))
1425 });
1426 let service_uses = self.provides.services.iter().any(|entry| {
1432 entry.command.contains(TOKEN)
1433 || entry.args.iter().any(|value| value.contains(TOKEN))
1434 || entry
1435 .cwd
1436 .as_deref()
1437 .is_some_and(|value| value.contains(TOKEN))
1438 || entry.env.values().any(|value| value.contains(TOKEN))
1439 });
1440 mcp_uses || service_uses
1441 }
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446 use super::*;
1447
1448 fn minimal_manifest_json() -> &'static str {
1449 r#"{
1450 "id": "hello-plugin",
1451 "name": "Hello Plugin",
1452 "version": "0.1.0",
1453 "provides": {
1454 "skills": ["hello-world"],
1455 "prompts": [
1456 {"id": "hello_preset", "name": "Hello Preset", "content": "Say hello."}
1457 ]
1458 }
1459 }"#
1460 }
1461
1462 #[test]
1463 fn parses_minimal_manifest() {
1464 let manifest = PluginManifest::parse_str(minimal_manifest_json()).expect("parse");
1465 assert_eq!(manifest.id, "hello-plugin");
1466 assert_eq!(manifest.version, "0.1.0");
1467 assert_eq!(manifest.provides.skills, vec!["hello-world".to_string()]);
1468 assert_eq!(manifest.provides.prompts.len(), 1);
1469 assert!(manifest.provides.mcp_servers.is_empty());
1470 assert!(manifest.artifacts.is_empty());
1471 manifest.validate().expect("minimal manifest is valid");
1472 }
1473
1474 #[test]
1475 fn parses_full_manifest_with_mcp_and_artifacts() {
1476 let json = r#"{
1477 "id": "nova_plugin",
1478 "name": "Nova",
1479 "version": "1.2.3-beta+build.7",
1480 "description": "Desktop control MCP server",
1481 "bamboo_min_version": "2026.7.0",
1482 "platforms": ["macos", "windows", "linux"],
1483 "provides": {
1484 "mcp_servers": [
1485 {
1486 "id": "nova",
1487 "enabled": true,
1488 "transport": {
1489 "type": "stdio",
1490 "command": "${platform_bin}",
1491 "args": ["--serve"],
1492 "cwd": "${plugin_dir}",
1493 "env": {"NOVA_HOME": "${plugin_dir}/data"}
1494 }
1495 }
1496 ],
1497 "workflows": ["daily-report.md"]
1498 },
1499 "artifacts": {
1500 "macos": {"url": "https://example.com/nova-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1501 "windows": {"url": "https://example.com/nova-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
1502 "linux": {"url": "https://example.com/nova-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
1503 }
1504 }"#;
1505
1506 let manifest = PluginManifest::parse_str(json).expect("parse full manifest");
1507 manifest.validate().expect("full manifest is valid");
1508 assert!(manifest.supports_platform(Platform::Macos));
1509 assert!(manifest.supports_platform(Platform::Windows));
1510 assert!(manifest.supports_platform(Platform::Linux));
1511
1512 let entry = &manifest.provides.mcp_servers[0];
1513 let plugin_dir = Path::new("/home/user/.bamboo/plugins/nova_plugin");
1514 let resolved = entry
1515 .resolve(plugin_dir, &manifest.id, Platform::Macos)
1516 .expect("resolve mcp entry");
1517 match resolved.transport {
1518 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
1519 assert_eq!(
1520 stdio.command,
1521 "/home/user/.bamboo/plugins/nova_plugin/bin/macos/nova_plugin"
1522 );
1523 assert_eq!(stdio.cwd.as_deref(), Some(plugin_dir.to_str().unwrap()));
1524 assert_eq!(
1525 stdio.env.get("NOVA_HOME").map(String::as_str),
1526 Some("/home/user/.bamboo/plugins/nova_plugin/data")
1527 );
1528 }
1529 _ => panic!("expected stdio transport"),
1530 }
1531 }
1532
1533 #[test]
1534 fn platform_bin_path_appends_exe_on_windows_only() {
1535 let dir = Path::new("/plugins/demo");
1536 assert_eq!(
1537 platform_bin_path(dir, "demo", Platform::Macos),
1538 PathBuf::from("/plugins/demo/bin/macos/demo")
1539 );
1540 assert_eq!(
1541 platform_bin_path(dir, "demo", Platform::Windows),
1542 PathBuf::from("/plugins/demo/bin/windows/demo.exe")
1543 );
1544 assert_eq!(
1545 platform_bin_path(dir, "demo", Platform::Linux),
1546 PathBuf::from("/plugins/demo/bin/linux/demo")
1547 );
1548 }
1549
1550 #[test]
1551 fn rejects_invalid_id() {
1552 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1553 manifest.id = "Bad Id!".to_string();
1554 let error = manifest.validate().expect_err("bad id should fail");
1555 assert!(error.to_string().contains("invalid plugin id"));
1556 }
1557
1558 #[test]
1559 fn rejects_bad_semver() {
1560 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1561 manifest.version = "latest".to_string();
1562 let error = manifest.validate().expect_err("bad version should fail");
1563 assert!(error.to_string().contains("invalid plugin version"));
1564 }
1565
1566 #[test]
1567 fn rejects_empty_platforms_list() {
1568 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1569 manifest.platforms = Some(vec![]);
1570 let error = manifest
1571 .validate()
1572 .expect_err("empty platforms should fail");
1573 assert!(error.to_string().contains("platforms"));
1574 }
1575
1576 #[test]
1577 fn rejects_duplicate_mcp_server_ids() {
1578 let json = r#"{
1579 "id": "dup",
1580 "name": "Dup",
1581 "version": "1.0.0",
1582 "provides": {
1583 "mcp_servers": [
1584 {"id": "a", "transport": {"type": "stdio", "command": "x"}},
1585 {"id": "a", "transport": {"type": "stdio", "command": "y"}}
1586 ]
1587 }
1588 }"#;
1589 let manifest = PluginManifest::parse_str(json).unwrap();
1590 let error = manifest
1591 .validate()
1592 .expect_err("duplicate mcp id should fail");
1593 assert!(error.to_string().contains("duplicate mcp server id"));
1594 }
1595
1596 #[test]
1597 fn rejects_traversal_in_skill_dir_and_bad_workflow_filename() {
1598 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1599 manifest.provides.skills = vec!["../escape".to_string()];
1600 assert!(manifest.validate().is_err());
1601
1602 let mut manifest2: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1603 manifest2.provides.skills = vec![];
1604 manifest2.provides.workflows = vec!["not-markdown.txt".to_string()];
1605 assert!(manifest2.validate().is_err());
1606 }
1607
1608 #[test]
1609 fn rejects_invalid_artifact_sha256() {
1610 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1611 manifest.artifacts.insert(
1612 "macos".to_string(),
1613 PluginArtifact {
1614 url: "https://example.com/x.tar.gz".to_string(),
1615 sha256: "not-hex".to_string(),
1616 },
1617 );
1618 let error = manifest.validate().expect_err("bad sha256 should fail");
1619 assert!(error.to_string().contains("sha256"));
1620 }
1621
1622 #[test]
1623 fn rejects_platform_bin_plugin_missing_an_artifact_for_a_supported_platform() {
1624 let json = r#"{
1628 "id": "binbacked",
1629 "name": "Bin Backed",
1630 "version": "1.0.0",
1631 "provides": {
1632 "mcp_servers": [
1633 {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1634 ]
1635 },
1636 "artifacts": {
1637 "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1638 "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1639 }
1640 }"#;
1641 let manifest = PluginManifest::parse_str(json).unwrap();
1642 let error = manifest
1643 .validate()
1644 .expect_err("missing linux artifact should fail");
1645 assert!(error.to_string().contains("linux"));
1646 }
1647
1648 #[test]
1649 fn platform_bin_plugin_is_valid_when_gate_narrows_to_covered_platforms() {
1650 let json = r#"{
1653 "id": "binbacked",
1654 "name": "Bin Backed",
1655 "version": "1.0.0",
1656 "platforms": ["macos", "windows"],
1657 "provides": {
1658 "mcp_servers": [
1659 {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1660 ]
1661 },
1662 "artifacts": {
1663 "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1664 "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1665 }
1666 }"#;
1667 let manifest = PluginManifest::parse_str(json).unwrap();
1668 manifest
1669 .validate()
1670 .expect("gate-narrowed binary plugin is valid");
1671 assert!(manifest.uses_platform_bin_token());
1672 }
1673
1674 #[test]
1675 fn rejects_artifact_for_platform_outside_the_gate() {
1676 let json = r#"{
1677 "id": "gated",
1678 "name": "Gated",
1679 "version": "1.0.0",
1680 "platforms": ["macos"],
1681 "artifacts": {
1682 "linux": {"url": "https://example.com/x-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
1683 }
1684 }"#;
1685 let manifest = PluginManifest::parse_str(json).unwrap();
1686 let error = manifest
1687 .validate()
1688 .expect_err("artifact outside gate should fail");
1689 assert!(error.to_string().contains("not in the `platforms` gate"));
1690 }
1691
1692 #[test]
1693 fn local_install_with_platform_bin_and_no_artifacts_is_valid() {
1694 let json = r#"{
1697 "id": "localbin",
1698 "name": "Local Bin",
1699 "version": "1.0.0",
1700 "provides": {
1701 "mcp_servers": [
1702 {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1703 ]
1704 }
1705 }"#;
1706 let manifest = PluginManifest::parse_str(json).unwrap();
1707 manifest
1708 .validate()
1709 .expect("local binary plugin without artifacts is valid");
1710 }
1711
1712 #[test]
1713 fn rejects_reserved_preset_id() {
1714 let json = r#"{
1715 "id": "reserver",
1716 "name": "Reserver",
1717 "version": "1.0.0",
1718 "provides": {
1719 "prompts": [
1720 {"id": "general_assistant", "name": "Nope", "content": "x"}
1721 ]
1722 }
1723 }"#;
1724 let manifest = PluginManifest::parse_str(json).unwrap();
1725 let error = manifest
1726 .validate()
1727 .expect_err("reserved preset id should fail");
1728 assert!(error.to_string().contains("prompt preset id"));
1729 assert!(!is_valid_preset_id("general_assistant"));
1730 }
1731
1732 #[test]
1733 fn rejects_unknown_artifact_platform_key() {
1734 let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1735 manifest.artifacts.insert(
1736 "solaris".to_string(),
1737 PluginArtifact {
1738 url: "https://example.com/x.tar.gz".to_string(),
1739 sha256: "a".repeat(64),
1740 },
1741 );
1742 let error = manifest
1743 .validate()
1744 .expect_err("unknown platform key should fail");
1745 assert!(error.to_string().contains("unknown platform key"));
1746 }
1747
1748 #[test]
1749 fn semver_shape_check() {
1750 assert!(is_plausible_semver("1.2.3"));
1751 assert!(is_plausible_semver("1.2.3-beta.1"));
1752 assert!(is_plausible_semver("1.2.3+build.7"));
1753 assert!(is_plausible_semver("1.2.3-beta+build"));
1754 assert!(!is_plausible_semver("1.2"));
1755 assert!(!is_plausible_semver("latest"));
1756 assert!(!is_plausible_semver(""));
1757 assert!(!is_plausible_semver("v1.2.3"));
1758 }
1759
1760 fn service_manifest_json(id: &str, command: &str) -> String {
1761 serde_json::json!({
1762 "id": "svc-plugin",
1763 "name": "Svc Plugin",
1764 "version": "1.0.0",
1765 "provides": {
1766 "services": [
1767 {"id": id, "command": command}
1768 ]
1769 }
1770 })
1771 .to_string()
1772 }
1773
1774 fn event_sink_manifest_value(
1775 protocol_version: u16,
1776 service_enabled: bool,
1777 ) -> serde_json::Value {
1778 serde_json::json!({
1779 "id": "event-plugin",
1780 "name": "Event Plugin",
1781 "version": "1.0.0",
1782 "provides": {
1783 "services": [{
1784 "id": "audit-service",
1785 "enabled": service_enabled,
1786 "command": PLATFORM_BIN_TOKEN,
1787 "input_protocol": "ndjson_v1"
1788 }],
1789 "event_sinks": [{
1790 "id": "audit-events",
1791 "service_id": "audit-service",
1792 "protocol": {
1793 "name": TOOL_EVENT_PROTOCOL_NAME,
1794 "version": protocol_version
1795 },
1796 "subscriptions": [{
1797 "id": FILE_CHANGED_SUBSCRIPTION_ID_V1,
1798 "tool_names": ["Write", "Edit"]
1799 }],
1800 "delivery": {
1801 "queue_capacity": DEFAULT_EVENT_SINK_QUEUE_CAPACITY,
1802 "max_event_bytes": MAX_TOOL_EVENT_JSON_BYTES
1803 },
1804 "requested_permissions": [OBSERVE_METADATA_PERMISSION]
1805 }]
1806 }
1807 })
1808 }
1809
1810 fn parse_event_sink_manifest(value: &serde_json::Value) -> PluginManifest {
1811 PluginManifest::parse_str(&value.to_string()).expect("parse event sink manifest")
1812 }
1813
1814 #[test]
1815 fn legacy_manifest_round_trip_omits_event_sinks() {
1816 let manifest = PluginManifest::parse_str(minimal_manifest_json()).expect("parse legacy");
1817 assert!(manifest.provides.event_sinks.is_empty());
1818
1819 let serialized = serde_json::to_value(&manifest).expect("serialize legacy manifest");
1820 assert!(serialized["provides"].get("event_sinks").is_none());
1821 assert!(!serde_json::to_string(&manifest)
1822 .expect("serialize legacy manifest bytes")
1823 .contains("event_sinks"));
1824 }
1825
1826 #[test]
1827 fn validates_v1_sink_with_safe_defaults_and_tool_filters() {
1828 let mut value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
1829 let sink = value["provides"]["event_sinks"][0]
1830 .as_object_mut()
1831 .expect("sink object");
1832 sink.remove("delivery");
1833 sink.remove("requested_permissions");
1834
1835 let manifest = parse_event_sink_manifest(&value);
1836 manifest.validate().expect("valid v1 event sink");
1837 let sink = &manifest.provides.event_sinks[0];
1838 assert_eq!(
1839 sink.delivery,
1840 EventSinkDeliveryLimits {
1841 queue_capacity: DEFAULT_EVENT_SINK_QUEUE_CAPACITY,
1842 max_event_bytes: MAX_TOOL_EVENT_JSON_BYTES as u32,
1843 extensions: BTreeMap::new(),
1844 }
1845 );
1846 assert_eq!(sink.requested_permissions.len(), 1);
1847 assert_eq!(
1848 sink.requested_permissions[0].as_str(),
1849 OBSERVE_METADATA_PERMISSION
1850 );
1851 assert_eq!(
1852 sink.subscriptions[0].tool_names,
1853 vec!["Write".to_string(), "Edit".to_string()]
1854 );
1855 assert_eq!(
1856 sink.capability_state(&manifest.provides.services[0], Some(Platform::Linux)),
1857 EventSinkCapabilityState::Eligible
1858 );
1859 }
1860
1861 #[test]
1862 fn future_tool_event_version_preserves_opaque_values_and_is_inactive() {
1863 let mut value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION + 1, true);
1864 value["provides"]["services"][0]
1865 .as_object_mut()
1866 .expect("service object")
1867 .remove("input_protocol");
1868 value["provides"]["event_sinks"][0]["future_sink_option"] =
1869 serde_json::json!({ "mode": "v2" });
1870 value["provides"]["event_sinks"][0]["protocol"]["negotiation"] =
1871 serde_json::json!("optional");
1872 value["provides"]["event_sinks"][0]["subscriptions"] = serde_json::json!([{
1873 "id": "tool.symbol_changed.v2",
1874 "tool_names": ["FutureTool"],
1875 "projection": "symbol"
1876 }]);
1877 value["provides"]["event_sinks"][0]["requested_permissions"] =
1878 serde_json::json!(["symbol_metadata_v2"]);
1879 value["provides"]["event_sinks"][0]["delivery"] = serde_json::json!({
1880 "queue_capacity": 64,
1881 "max_event_bytes": MAX_EVENT_SINK_EVENT_BYTES,
1882 "batch_size": 8
1883 });
1884
1885 let manifest = parse_event_sink_manifest(&value);
1886 manifest
1887 .validate()
1888 .expect("future version must degrade instead of failing validation");
1889 let sink = &manifest.provides.event_sinks[0];
1890 assert_eq!(
1891 manifest.provides.services[0].input_protocol,
1892 ServiceInputProtocol::None,
1893 "future protocols must remain installable and inactive when the current host input protocol is absent"
1894 );
1895 assert_eq!(sink.subscriptions[0].id.as_str(), "tool.symbol_changed.v2");
1896 assert_eq!(sink.requested_permissions.len(), 1);
1897 assert_eq!(sink.requested_permissions[0].as_str(), "symbol_metadata_v2");
1898 let serialized = serde_json::to_value(&manifest).expect("serialize future extensions");
1899 assert_eq!(
1900 serialized["provides"]["event_sinks"][0]["future_sink_option"]["mode"],
1901 "v2"
1902 );
1903 assert_eq!(
1904 serialized["provides"]["event_sinks"][0]["protocol"]["negotiation"],
1905 "optional"
1906 );
1907 assert_eq!(
1908 serialized["provides"]["event_sinks"][0]["delivery"]["batch_size"],
1909 8
1910 );
1911 assert_eq!(
1912 serialized["provides"]["event_sinks"][0]["subscriptions"][0]["projection"],
1913 "symbol"
1914 );
1915 assert_eq!(
1916 sink.capability_state(&manifest.provides.services[0], Some(Platform::Linux)),
1917 EventSinkCapabilityState::Inactive {
1918 detail: EventSinkInactiveReason::UnsupportedProtocolVersion {
1919 requested: TOOL_EVENT_V1_SCHEMA_VERSION + 1,
1920 supported: TOOL_EVENT_V1_SCHEMA_VERSION,
1921 },
1922 }
1923 );
1924 }
1925
1926 #[test]
1927 fn tool_event_v1_rejects_unknown_fields_in_every_nested_scope() {
1928 for path in ["sink", "protocol", "delivery", "subscription"] {
1929 let mut value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
1930 match path {
1931 "sink" => {
1932 value["provides"]["event_sinks"][0]["platform"] = serde_json::json!(["macos"])
1933 }
1934 "protocol" => {
1935 value["provides"]["event_sinks"][0]["protocol"]["negotiation"] =
1936 serde_json::json!("required")
1937 }
1938 "delivery" => {
1939 value["provides"]["event_sinks"][0]["delivery"]["queue_capcity"] =
1940 serde_json::json!(4)
1941 }
1942 "subscription" => {
1943 value["provides"]["event_sinks"][0]["subscriptions"][0]["projection"] =
1944 serde_json::json!("full")
1945 }
1946 _ => unreachable!(),
1947 }
1948 let error = parse_event_sink_manifest(&value)
1949 .validate()
1950 .expect_err("ToolEventV1 typo/extension must fail closed");
1951 assert!(
1952 error.to_string().contains("unknown field"),
1953 "scope={path}, error={error}"
1954 );
1955 }
1956 }
1957
1958 #[test]
1959 fn disabled_service_and_platform_mismatch_are_explicitly_inactive() {
1960 let value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, false);
1961 let manifest = parse_event_sink_manifest(&value);
1962 manifest
1963 .validate()
1964 .expect("disabled service is declarative");
1965 assert_eq!(
1966 manifest.provides.event_sinks[0]
1967 .capability_state(&manifest.provides.services[0], Some(Platform::Linux)),
1968 EventSinkCapabilityState::Inactive {
1969 detail: EventSinkInactiveReason::ServiceDisabled,
1970 }
1971 );
1972
1973 let mut value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
1974 value["provides"]["event_sinks"][0]["platforms"] = serde_json::json!(["macos"]);
1975 let manifest = parse_event_sink_manifest(&value);
1976 manifest.validate().expect("narrow platform gate is valid");
1977 let sink = &manifest.provides.event_sinks[0];
1978 assert_eq!(
1979 sink.capability_state(&manifest.provides.services[0], Some(Platform::Linux)),
1980 EventSinkCapabilityState::Inactive {
1981 detail: EventSinkInactiveReason::PlatformIneligible,
1982 }
1983 );
1984 assert_eq!(
1985 sink.capability_state(&manifest.provides.services[0], None),
1986 EventSinkCapabilityState::Inactive {
1987 detail: EventSinkInactiveReason::PlatformIneligible,
1988 }
1989 );
1990 }
1991
1992 #[test]
1993 fn rejects_missing_or_duplicate_sink_ownership() {
1994 let mut missing_service = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
1995 missing_service["provides"]["event_sinks"][0]["service_id"] =
1996 serde_json::json!("foreign-service");
1997 let error = parse_event_sink_manifest(&missing_service)
1998 .validate()
1999 .expect_err("cross-plugin/missing service reference must fail");
2000 assert!(error.to_string().contains("same plugin"));
2001
2002 let mut duplicate = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2003 let clone = duplicate["provides"]["event_sinks"][0].clone();
2004 duplicate["provides"]["event_sinks"]
2005 .as_array_mut()
2006 .expect("sinks array")
2007 .push(clone);
2008 let error = parse_event_sink_manifest(&duplicate)
2009 .validate()
2010 .expect_err("duplicate sink id must fail");
2011 assert!(error.to_string().contains("duplicate event sink id"));
2012 }
2013
2014 #[test]
2015 fn tool_event_v1_requires_ndjson_service_input() {
2016 let mut value = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2017 value["provides"]["services"][0]
2018 .as_object_mut()
2019 .expect("service object")
2020 .remove("input_protocol");
2021
2022 let error = parse_event_sink_manifest(&value)
2023 .validate()
2024 .expect_err("ToolEventV1 cannot route into a null-stdin service");
2025 assert!(error.to_string().contains("input_protocol 'ndjson_v1'"));
2026 }
2027
2028 #[test]
2029 fn v1_rejects_unknown_or_incompatible_observation_requests() {
2030 let mut unknown_subscription =
2031 event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2032 unknown_subscription["provides"]["event_sinks"][0]["subscriptions"][0]["id"] =
2033 serde_json::json!("tool.unknown.v1");
2034 assert!(parse_event_sink_manifest(&unknown_subscription)
2035 .validate()
2036 .expect_err("unknown v1 subscription")
2037 .to_string()
2038 .contains("unsupported ToolEventV1 subscription"));
2039
2040 let mut unknown_permission = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2041 unknown_permission["provides"]["event_sinks"][0]["requested_permissions"] =
2042 serde_json::json!([OBSERVE_METADATA_PERMISSION, "everything"]);
2043 assert!(parse_event_sink_manifest(&unknown_permission)
2044 .validate()
2045 .expect_err("unknown v1 permission")
2046 .to_string()
2047 .contains("unsupported ToolEventV1 observation permission"));
2048
2049 let mut payload_without_path =
2050 event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2051 payload_without_path["provides"]["event_sinks"][0]["requested_permissions"] =
2052 serde_json::json!([OBSERVE_METADATA_PERMISSION, OBSERVE_CONTENT_PERMISSION]);
2053 assert!(parse_event_sink_manifest(&payload_without_path)
2054 .validate()
2055 .expect_err("content without path permission")
2056 .to_string()
2057 .contains("required paths permission"));
2058 }
2059
2060 #[test]
2061 fn rejects_malformed_protocol_and_duplicate_open_values() {
2062 let mut version_zero = event_sink_manifest_value(0, true);
2063 assert!(parse_event_sink_manifest(&version_zero)
2064 .validate()
2065 .expect_err("protocol version zero")
2066 .to_string()
2067 .contains("non-zero"));
2068
2069 version_zero["provides"]["event_sinks"][0]["protocol"] =
2070 serde_json::json!({"name": "tool_evnet", "version": 2});
2071 assert!(parse_event_sink_manifest(&version_zero)
2072 .validate()
2073 .expect_err("unknown protocol family")
2074 .to_string()
2075 .contains("unknown protocol family"));
2076
2077 let mut duplicate_subscription =
2078 event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2079 let subscription =
2080 duplicate_subscription["provides"]["event_sinks"][0]["subscriptions"][0].clone();
2081 duplicate_subscription["provides"]["event_sinks"][0]["subscriptions"]
2082 .as_array_mut()
2083 .expect("subscriptions")
2084 .push(subscription);
2085 assert!(parse_event_sink_manifest(&duplicate_subscription)
2086 .validate()
2087 .expect_err("duplicate subscription")
2088 .to_string()
2089 .contains("repeats subscription"));
2090
2091 let mut duplicate_permission =
2092 event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2093 duplicate_permission["provides"]["event_sinks"][0]["requested_permissions"] =
2094 serde_json::json!([OBSERVE_METADATA_PERMISSION, OBSERVE_METADATA_PERMISSION]);
2095 assert!(parse_event_sink_manifest(&duplicate_permission)
2096 .validate()
2097 .expect_err("duplicate permission")
2098 .to_string()
2099 .contains("repeats observation permission"));
2100 }
2101
2102 #[test]
2103 fn rejects_duplicate_tool_filters_and_excessive_declared_buffering() {
2104 let mut duplicate_tool = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2105 duplicate_tool["provides"]["event_sinks"][0]["subscriptions"][0]["tool_names"] =
2106 serde_json::json!(["Write", "Write"]);
2107 assert!(parse_event_sink_manifest(&duplicate_tool)
2108 .validate()
2109 .expect_err("duplicate tool filter")
2110 .to_string()
2111 .contains("repeats tool name"));
2112
2113 let mut excessive = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION + 1, true);
2114 excessive["provides"]["event_sinks"][0]["delivery"] = serde_json::json!({
2115 "queue_capacity": 65,
2116 "max_event_bytes": MAX_EVENT_SINK_EVENT_BYTES
2117 });
2118 assert!(parse_event_sink_manifest(&excessive)
2119 .validate()
2120 .expect_err("aggregate buffer budget must be bounded")
2121 .to_string()
2122 .contains("aggregate delivery buffering"));
2123
2124 let mut v1_oversize = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION, true);
2125 v1_oversize["provides"]["event_sinks"][0]["delivery"]["max_event_bytes"] =
2126 serde_json::json!(MAX_TOOL_EVENT_JSON_BYTES as u32 + 1);
2127 assert!(parse_event_sink_manifest(&v1_oversize)
2128 .validate()
2129 .expect_err("ToolEventV1 wire maximum")
2130 .to_string()
2131 .contains("ToolEventV1 delivery limits"));
2132
2133 let mut queue_oversize = event_sink_manifest_value(TOOL_EVENT_V1_SCHEMA_VERSION + 1, true);
2134 queue_oversize["provides"]["event_sinks"][0]["delivery"]["queue_capacity"] =
2135 serde_json::json!(MAX_EVENT_SINK_QUEUE_CAPACITY + 1);
2136 assert!(parse_event_sink_manifest(&queue_oversize)
2137 .validate()
2138 .expect_err("absolute queue bound")
2139 .to_string()
2140 .contains("absolute host bounds"));
2141 }
2142
2143 #[test]
2144 fn parses_and_validates_minimal_service_entry() {
2145 let json = service_manifest_json("svc", PLATFORM_BIN_TOKEN);
2146 let manifest = PluginManifest::parse_str(&json).unwrap();
2147 manifest.validate().expect("minimal service entry is valid");
2148 let entry = &manifest.provides.services[0];
2149 assert!(entry.enabled);
2150 assert_eq!(entry.health_check.kind, HealthCheckKind::ProcessAlive);
2151 assert_eq!(entry.graceful_shutdown.signal, ShutdownSignal::Term);
2152 assert_eq!(entry.input_protocol, ServiceInputProtocol::None);
2153 assert!(
2154 serde_json::to_value(entry)
2155 .expect("serialize legacy service")
2156 .get("input_protocol")
2157 .is_none(),
2158 "the default must remain absent from reserialized legacy manifests"
2159 );
2160 assert!(manifest.uses_platform_bin_token());
2161 }
2162
2163 #[test]
2164 fn parses_and_resolves_explicit_ndjson_v1_service_input() {
2165 let mut value: serde_json::Value =
2166 serde_json::from_str(&service_manifest_json("svc", PLATFORM_BIN_TOKEN)).unwrap();
2167 value["provides"]["services"][0]["input_protocol"] = serde_json::json!("ndjson_v1");
2168 let manifest = PluginManifest::parse_str(&value.to_string()).expect("parse ndjson input");
2169 manifest.validate().expect("ndjson service is valid");
2170 let entry = &manifest.provides.services[0];
2171 assert_eq!(entry.input_protocol, ServiceInputProtocol::NdjsonV1);
2172 assert_eq!(
2173 entry
2174 .resolve(
2175 Path::new("/plugins/svc-plugin"),
2176 &manifest.id,
2177 Platform::Linux
2178 )
2179 .input_protocol,
2180 ServiceInputProtocol::NdjsonV1
2181 );
2182 }
2183
2184 #[test]
2185 fn rejects_service_command_that_is_not_exactly_the_platform_bin_token() {
2186 for bad_command in ["/usr/bin/env", "nova", "${platform_bin} --serve", ""] {
2187 let json = service_manifest_json("svc", bad_command);
2188 let manifest = PluginManifest::parse_str(&json).unwrap();
2189 let error = manifest
2190 .validate()
2191 .expect_err("non-token service command must be rejected");
2192 assert!(matches!(error, PluginError::InvalidManifest(_)));
2193 }
2194 }
2195
2196 #[test]
2197 fn rejects_duplicate_service_ids() {
2198 let json = serde_json::json!({
2199 "id": "svc-plugin",
2200 "name": "Svc",
2201 "version": "1.0.0",
2202 "provides": {
2203 "services": [
2204 {"id": "a", "command": PLATFORM_BIN_TOKEN},
2205 {"id": "a", "command": PLATFORM_BIN_TOKEN}
2206 ]
2207 }
2208 })
2209 .to_string();
2210 let manifest = PluginManifest::parse_str(&json).unwrap();
2211 let error = manifest
2212 .validate()
2213 .expect_err("duplicate service id should fail");
2214 assert!(error.to_string().contains("duplicate service id"));
2215 }
2216
2217 #[test]
2218 fn rejects_tcp_and_http_health_check_missing_target() {
2219 for kind in ["tcp", "http"] {
2220 let json = serde_json::json!({
2221 "id": "svc-plugin",
2222 "name": "Svc",
2223 "version": "1.0.0",
2224 "provides": {
2225 "services": [
2226 {"id": "a", "command": PLATFORM_BIN_TOKEN, "health_check": {"kind": kind}}
2227 ]
2228 }
2229 })
2230 .to_string();
2231 let manifest = PluginManifest::parse_str(&json).unwrap();
2232 let error = manifest
2233 .validate()
2234 .expect_err("tcp/http health_check without a target should fail");
2235 assert!(error.to_string().contains("target"));
2236 }
2237 }
2238
2239 #[test]
2240 fn accepts_tcp_health_check_with_target() {
2241 let json = serde_json::json!({
2242 "id": "svc-plugin",
2243 "name": "Svc",
2244 "version": "1.0.0",
2245 "provides": {
2246 "services": [
2247 {"id": "a", "command": PLATFORM_BIN_TOKEN, "health_check": {"kind": "tcp", "target": "127.0.0.1:9000"}}
2248 ]
2249 }
2250 })
2251 .to_string();
2252 let manifest = PluginManifest::parse_str(&json).unwrap();
2253 manifest
2254 .validate()
2255 .expect("tcp health_check with target is valid");
2256 }
2257
2258 #[test]
2259 fn services_missing_artifact_for_a_supported_platform_is_rejected() {
2260 let json = serde_json::json!({
2264 "id": "svc-plugin",
2265 "name": "Svc",
2266 "version": "1.0.0",
2267 "provides": {
2268 "services": [
2269 {"id": "a", "command": PLATFORM_BIN_TOKEN}
2270 ]
2271 },
2272 "artifacts": {
2273 "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
2274 "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
2275 }
2276 })
2277 .to_string();
2278 let manifest = PluginManifest::parse_str(&json).unwrap();
2279 let error = manifest
2280 .validate()
2281 .expect_err("missing linux artifact for a service-only plugin should fail");
2282 assert!(error.to_string().contains("linux"));
2283 }
2284
2285 #[test]
2286 fn resolve_service_entry_substitutes_tokens_and_pins_command_to_platform_bin() {
2287 let json = serde_json::json!({
2288 "id": "svc-plugin",
2289 "name": "Svc",
2290 "version": "1.0.0",
2291 "provides": {
2292 "services": [
2293 {
2294 "id": "a",
2295 "command": PLATFORM_BIN_TOKEN,
2296 "args": ["--config", "${plugin_dir}/data"],
2297 "cwd": "${plugin_dir}",
2298 "env": {"HOME_DIR": "${plugin_dir}/home"}
2299 }
2300 ]
2301 }
2302 })
2303 .to_string();
2304 let manifest = PluginManifest::parse_str(&json).unwrap();
2305 manifest.validate().expect("valid");
2306 let entry = &manifest.provides.services[0];
2307 let plugin_dir = Path::new("/home/user/.bamboo/plugins/svc-plugin");
2308 let resolved = entry.resolve(plugin_dir, &manifest.id, Platform::Linux);
2309 assert_eq!(
2310 resolved.command,
2311 PathBuf::from("/home/user/.bamboo/plugins/svc-plugin/bin/linux/svc-plugin")
2312 );
2313 assert_eq!(
2314 resolved.args,
2315 vec![
2316 "--config".to_string(),
2317 "/home/user/.bamboo/plugins/svc-plugin/data".to_string()
2318 ]
2319 );
2320 assert_eq!(resolved.cwd, Some(plugin_dir.to_path_buf()));
2321 assert_eq!(
2322 resolved.env.get("HOME_DIR").map(String::as_str),
2323 Some("/home/user/.bamboo/plugins/svc-plugin/home")
2324 );
2325 }
2326
2327 #[test]
2328 fn plugin_id_rules() {
2329 assert!(is_valid_plugin_id("hello-plugin"));
2330 assert!(is_valid_plugin_id("nova_plugin_2"));
2331 assert!(!is_valid_plugin_id(""));
2332 assert!(!is_valid_plugin_id("Hello"));
2333 assert!(!is_valid_plugin_id("hello plugin"));
2334 assert!(!is_valid_plugin_id(&"a".repeat(MAX_PLUGIN_ID_LEN + 1)));
2335 }
2336}