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