Skip to main content

bamboo_plugin/
manifest.rs

1//! `plugin.json` manifest schema.
2//!
3//! A plugin bundle is a directory (installed at `~/.bamboo/plugins/<id>/`) with a
4//! `plugin.json` at its root describing what it *provides*: MCP servers, skills,
5//! prompt presets, workflows, supervised services, and ToolEvent sinks. This
6//! module defines that schema and a
7//! handful of pure, side-effect-free helpers (validation + `${...}` token
8//! substitution) that the installer (a later agent) builds on.
9//!
10//! # Directory layout convention
11//!
12//! ```text
13//! ~/.bamboo/plugins/<id>/
14//!   plugin.json          <- this manifest
15//!   skills/<skill-dir>/SKILL.md   (one or more, referenced by `provides.skills`)
16//!   prompts/              (optional; unused by the inline prompt design, see below)
17//!   workflows/<name>.md   (referenced by `provides.workflows`)
18//!   bin/<platform>/<id>[.exe]     (optional per-platform binary, see substitution contract)
19//! ```
20//!
21//! # Design decision: inline prompts, not file references
22//!
23//! `provides.prompts` is a `Vec<PluginPromptPreset>` with the preset content
24//! inlined directly in `plugin.json` (mirroring bamboo-server's
25//! `StoredPromptPreset { id, name, description?, content }`), rather than a list
26//! of filenames under `prompts/`. Rationale: prompt presets are small, and
27//! inlining keeps `plugin.json` a single self-contained source of truth the
28//! installer can validate and append into `prompt-presets.json` without a second
29//! file-read pass or an extra path-traversal surface. A future manifest version
30//! could add a file-reference variant if presets grow large enough to want
31//! external editing.
32//!
33//! # Substitution contract for `mcp_servers[].transport.stdio.{command,args,cwd,env}`
34//!
35//! Stdio MCP server commands may reference two tokens, resolved by the
36//! installer at install/registration time (see [`substitute_tokens`]):
37//!
38//! - `${plugin_dir}` — the absolute path to the installed plugin's root
39//!   directory (i.e. the directory containing `plugin.json`).
40//! - `${platform_bin}` — the absolute path to this plugin's per-platform
41//!   binary, resolved as `<plugin_dir>/bin/<platform>/<plugin id>[.exe on windows]`
42//!   where `<platform>` is one of `macos` | `windows` | `linux` (matching
43//!   [`Platform::as_str`]). This is a fixed naming convention (binary filename
44//!   == manifest `id`, `.exe` suffix only on Windows) so a single manifest
45//!   works across platforms without per-OS conditionals in `plugin.json` — if a
46//!   plugin needs a different binary name, it can still express that by joining
47//!   directly, e.g. `"${plugin_dir}/bin/${platform}/nova"` — but `${platform}`
48//!   alone is intentionally NOT provided as a token (see [`substitute_tokens`]
49//!   doc) to keep the contract to exactly two tokens.
50//!
51//! Tokens are substituted in `command`, each element of `args`, `cwd`, and each
52//! value in `env` (not env *keys*, and not in `url` for sse/streamable_http —
53//! remote endpoints have no plugin-local path to inject).
54
55use 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/// Target OS gate / per-platform artifact key.
69///
70/// Kept as a 3-way enum (rather than a free-form string) so `platforms` /
71/// `${platform_bin}` resolution / artifact selection all agree on the exact
72/// same three spellings.
73#[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    /// The platform this process is currently running on, if it is one of the
83    /// three Bamboo supports. `None` for anything else (e.g. `freebsd`) — a
84    /// platform gate should treat that as "not supported" rather than guess.
85    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    /// Parse the lowercase spelling used both in `plugin.json` and in
98    /// `std::env::consts::OS` (which already yields "macos"/"windows"/"linux").
99    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/// A single MCP server this plugin wants to register, shaped like
116/// [`bamboo_domain::mcp_config::McpServerConfig`] but with `${plugin_dir}` /
117/// `${platform_bin}` tokens allowed in the stdio transport's path-shaped
118/// fields. See the module docs for the substitution contract.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct McpServerManifestEntry {
121    /// Server id — becomes the `mcpServers` map key once registered.
122    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/// Transport variants a manifest can declare. Mirrors
139/// [`bamboo_domain::mcp_config::TransportConfig`]'s three transports, minus
140/// the fields the installer fills in with sensible defaults at registration
141/// time (timeouts, reconnect policy) — a manifest author shouldn't need to
142/// know Bamboo's default timeout values.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(tag = "type", rename_all = "snake_case")]
145pub enum McpTransportManifest {
146    Stdio {
147        /// May contain `${plugin_dir}` / `${platform_bin}`.
148        command: String,
149        #[serde(default)]
150        args: Vec<String>,
151        /// May contain `${plugin_dir}` / `${platform_bin}`.
152        #[serde(default, skip_serializing_if = "Option::is_none")]
153        cwd: Option<String>,
154        /// Values (not keys) may contain `${plugin_dir}` / `${platform_bin}`.
155        #[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    /// Resolve this manifest entry into a real
173    /// [`bamboo_domain::mcp_config::McpServerConfig`], substituting
174    /// `${plugin_dir}` / `${platform_bin}` tokens and filling in Bamboo's
175    /// standard defaults for timeouts/reconnect. Pure — does not touch disk,
176    /// does not start anything. The caller (installer) is responsible for
177    /// merging the result into `config.json` and calling
178    /// `mcp_manager.start_server`.
179    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
255/// Substitute `${plugin_dir}` and `${platform_bin}` in `template`. Unknown
256/// `${...}` tokens are left untouched (forward-compatible: a newer manifest
257/// using a token an older Bamboo doesn't know about degrades to a literal
258/// string rather than failing).
259pub 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
274/// Resolve the fixed-convention per-platform binary path:
275/// `<plugin_dir>/bin/<platform>/<plugin_id>[.exe]`.
276pub 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
288/// Literal token a [`ServiceManifestEntry::command`] must equal EXACTLY (no
289/// PATH resolution, no ambient binaries — see [`ServiceManifestEntry`]'s
290/// docs and `PluginManifest::validate`). Also used by
291/// [`PluginManifest::uses_platform_bin_token`].
292pub const PLATFORM_BIN_TOKEN: &str = "${platform_bin}";
293
294/// How [`ServiceManager`](../../bamboo_server/service_manager/index.html)
295/// (bamboo-server) should poll a running service for liveness. `ProcessAlive`
296/// is the v1 default (no `target`); `Tcp`/`Http` additionally require a
297/// `target` (validated in [`PluginManifest::validate`]).
298#[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/// Health-check policy for a [`ServiceManifestEntry`].
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct HealthCheckSpec {
317    pub kind: HealthCheckKind,
318    /// Required (non-empty) for `Tcp` (`host:port`) / `Http` (a URL);
319    /// unused for `ProcessAlive`. Validated in [`PluginManifest::validate`].
320    #[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/// Signal a service's graceful shutdown sends before escalating to a hard
340/// kill. `Term` (SIGTERM on unix; a best-effort equivalent request on
341/// Windows before `TerminateProcess`) is the default; `None` skips the
342/// graceful signal entirely and kills immediately.
343#[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/// Graceful-shutdown policy for a [`ServiceManifestEntry`].
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct GracefulShutdown {
358    #[serde(default)]
359    pub signal: ShutdownSignal,
360    /// How long to wait after `signal` before escalating to SIGKILL /
361    /// `TerminateProcess`.
362    #[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/// Optional stdin wire contract for a supervised plugin service.
376///
377/// `None` preserves the original service behavior: stdin is connected to the
378/// null device and Bamboo never creates an input writer. `NdjsonV1` opts the
379/// verified service binary into one JSON value per line over a pipe owned by
380/// the exact supervised process generation.
381#[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/// A long-running service this plugin wants supervised (issue #479, prereq
396/// for epic #477 — standalone connectors distributed as plugins). The
397/// highest-trust artifact kind a plugin can declare: unlike an MCP stdio
398/// server (whose `command` is free-form — see [`McpServerManifestEntry`]), a
399/// service's `command` MUST be exactly [`PLATFORM_BIN_TOKEN`] — no PATH
400/// resolution, no ambient binaries. It may only ever execute the plugin's
401/// own verified, sha256-pinned per-platform binary (see
402/// [`PluginArtifact`]'s archive contract) resolved via
403/// [`platform_bin_path`].
404///
405/// `args`/`cwd`/`env` (values only) accept the same `${plugin_dir}`/
406/// `${platform_bin}` substitution as MCP stdio entries — see
407/// [`substitute_tokens`].
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct ServiceManifestEntry {
410    /// Service id — becomes the key bamboo-server's `ServiceManager` and
411    /// provenance (`RegisteredCapabilities::service_ids`) key off.
412    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    /// MUST validate as exactly [`PLATFORM_BIN_TOKEN`] — see the type docs.
418    pub command: String,
419    #[serde(default)]
420    pub args: Vec<String>,
421    /// May contain `${plugin_dir}` / `${platform_bin}`.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub cwd: Option<String>,
424    /// Values (not keys) may contain `${plugin_dir}` / `${platform_bin}`.
425    #[serde(default)]
426    pub env: HashMap<String, String>,
427    #[serde(default)]
428    pub health_check: HealthCheckSpec,
429    /// Reuses [`bamboo_domain::mcp_config::ReconnectConfig`]'s shape
430    /// (enabled/initial_backoff_ms/max_backoff_ms/max_attempts) per the
431    /// issue's design.
432    #[serde(default)]
433    pub restart_policy: bamboo_domain::mcp_config::ReconnectConfig,
434    #[serde(default)]
435    pub graceful_shutdown: GracefulShutdown,
436    /// Explicit opt-in to Bamboo-owned service stdin. Omitted/legacy entries
437    /// remain `none` and retain the pre-input null-stdin behavior.
438    #[serde(default, skip_serializing_if = "ServiceInputProtocol::is_none")]
439    pub input_protocol: ServiceInputProtocol,
440}
441
442/// Protocol family and wire version requested by an event sink.
443///
444/// The family stays an open string at parse time. Validation accepts Bamboo's
445/// `tool_event` family; future non-zero versions remain installable and are
446/// explicitly reconciled as inactive until supported by the host.
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448pub struct EventSinkProtocolManifest {
449    pub name: String,
450    pub version: u16,
451    /// Opaque fields reserved for future protocol versions. ToolEventV1 is a
452    /// closed schema and rejects these during validation; a future version is
453    /// kept parseable (and inactive) without silently losing its extensions.
454    #[serde(default, flatten)]
455    pub extensions: BTreeMap<String, serde_json::Value>,
456}
457
458/// Declarative queue/event bounds. The router introduced by #905 owns their
459/// allocation and enforcement; #903 validates the supported v1 envelope.
460#[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
542/// Stable v1 observation-permission spellings. These are requests, not grants;
543/// #907 applies host policy before serialization.
544pub 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/// Forward-compatible observation permission requested by an event sink.
551///
552/// The host interprets the known ToolEventV1 spellings during validation while
553/// retaining unknown values from future protocol versions as opaque ids.
554#[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/// One open subscription id plus an optional canonical-tool-name filter. Both
573/// stay as bounded strings so a future protocol version can be installed and
574/// represented as degraded without older serde code rejecting it.
575#[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/// A ToolEvent consumer backed by this plugin's own verified service.
585#[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    /// Optional sink-specific platform gate. It may only narrow the plugin's
597    /// own gate. A sink is inactive when the current host is not listed.
598    #[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    /// The manifest narrows delivery using a field that the host has not
615    /// granted this sink permission to observe. Keeping the sink inactive
616    /// avoids turning delivery versus non-delivery into a side channel.
617    ObservationPermissionNotGranted {
618        permission: ObservationPermissionId,
619    },
620}
621
622/// Eligibility emitted by pure reconciliation. `Eligible` intentionally does
623/// not claim that #903 has started delivery; the later router owns activation.
624#[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/// A [`ServiceManifestEntry`] with all `${...}` tokens substituted and
664/// `command` resolved to the concrete per-platform binary path — pure, ready
665/// for bamboo-server's `ServiceManager` to spawn. Analogous to
666/// [`McpServerManifestEntry::resolve`]'s `McpServerConfig` output.
667#[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    /// Resolve this manifest entry against a concrete `plugin_dir`/platform.
684    /// Pure — does not touch disk, does not spawn anything. `command` is
685    /// always [`platform_bin_path`] (never `substitute_tokens`'d from
686    /// `self.command`) — validation already pins `self.command` to exactly
687    /// [`PLATFORM_BIN_TOKEN`], and `platform_bin_path` IS that token's
688    /// resolution.
689    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/// Inline prompt preset, mirroring bamboo-server's
727/// `StoredPromptPreset { id, name, description?, content }` (see
728/// `crates/app/bamboo-server/src/handlers/agent/prompt_presets/types.rs`).
729/// `id` must satisfy the same rule bamboo-server enforces:
730/// `[a-z0-9_]`, length <= 80 (see [`is_valid_preset_id`]).
731#[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/// Per-platform downloadable artifact for the URL-install source (fetch logic
741/// is a later agent's job — this is schema-only).
742///
743/// # Archive contract (pinned for Wave-2 fetch code + plugin authors)
744///
745/// `url` points at an **archive**, never a raw executable: a `.zip` **or**
746/// `.tar.gz`/`.tgz`. The installer:
747/// 1. downloads it, verifies [`Self::sha256`] (lowercase hex, over the raw
748///    archive bytes) BEFORE unpacking anything,
749/// 2. unpacks it, and expects **exactly one executable at the archive root**
750///    named `<plugin id>` (unix) or `<plugin id>.exe` (windows),
751/// 3. places that executable at `<plugin_dir>/bin/<platform>/<plugin id>[.exe]`
752///    — the exact path [`platform_bin_path`] resolves, so `${platform_bin}`
753///    then points at it.
754///
755/// This matches how real release assets ship (e.g. nova's are
756/// `nova-v<ver>-<triple>.zip` with `nova.exe` at the zip root, and a
757/// `.tar.gz` with `nova` at the tar root) — a plugin does NOT have to
758/// re-layout its release binaries, it just declares the archive URL + hash.
759#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct PluginArtifact {
761    /// Archive URL (`.zip` / `.tar.gz` / `.tgz`) — see the type-level docs.
762    pub url: String,
763    /// Lowercase hex-encoded sha256 of the raw archive bytes, verified by the
764    /// installer after download and BEFORE unpacking.
765    pub sha256: String,
766}
767
768/// What a plugin provides: any subset of MCP servers, skills, prompt presets,
769/// and (future) workflows.
770#[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    /// Directory names under `<plugin_dir>/skills/`. Each must contain a
775    /// `SKILL.md`. These are discovered *in place* (no copy, no symlink) once
776    /// the skill-discovery extension picks up the plugin dir — see
777    /// `bamboo-skills`' `SkillDirectorySource::Plugin`. Declaring them here is
778    /// for provenance/validation, not for making discovery work.
779    #[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    /// `.md` filenames under `<plugin_dir>/workflows/`. They remain in place
784    /// and are discovered as read-only legacy Skill adapters; installation
785    /// never copies them into a user's global workflow directory.
786    #[serde(default, skip_serializing_if = "Vec::is_empty")]
787    pub workflows: Vec<String>,
788    /// Long-running services this plugin wants supervised — see
789    /// [`ServiceManifestEntry`]. Issue #479 (prereq for epic #477).
790    #[serde(default, skip_serializing_if = "Vec::is_empty")]
791    pub services: Vec<ServiceManifestEntry>,
792    /// ToolEvent consumers backed by this plugin's own verified services.
793    #[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/// The `plugin.json` manifest.
809#[derive(Debug, Clone, Serialize, Deserialize)]
810pub struct PluginManifest {
811    /// Stable identifier, `[a-z0-9_-]`, used as the install directory name
812    /// (`~/.bamboo/plugins/<id>/`) and default binary name.
813    pub id: String,
814    pub name: String,
815    /// Semver-shaped string (`major.minor.patch[-pre][+build]`). Validated
816    /// structurally by [`PluginManifest::validate`]; actual semver comparison
817    /// for upgrade decisions is the installer's job (not depended on here to
818    /// avoid pulling in a semver crate for a foundation crate).
819    pub version: String,
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub description: Option<String>,
822    /// Minimum Bamboo version required, same shape as `version`.
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub bamboo_min_version: Option<String>,
825    /// Platform gate. `None` means "no restriction" (all platforms). `Some([])`
826    /// is rejected by [`PluginManifest::validate`] (an explicit empty gate
827    /// would mean "installable nowhere", which is never intended).
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub platforms: Option<Vec<Platform>>,
830    #[serde(default)]
831    pub provides: PluginProvides,
832    /// Per-platform downloadable bundle for the URL-install source. Keys are
833    /// the same lowercase strings as [`Platform::as_str`] (kept as `String`
834    /// rather than `Platform` here so an unknown/typo'd key surfaces as a
835    /// clear validation error instead of a silent serde failure on the whole
836    /// map — see [`PluginManifest::validate`]).
837    #[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
844/// Preset ids the plugin system must NOT let a plugin claim, because
845/// bamboo-server reserves them. `"general_assistant"` is its
846/// `DEFAULT_PRESET_ID` (see
847/// `crates/app/bamboo-server/src/handlers/agent/prompt_presets/types.rs`);
848/// `sanitize_store` there silently STRIPS any stored preset with that id, so a
849/// plugin declaring it would pass a naive `[a-z0-9_]` check but then vanish at
850/// runtime with no error. Reject it up front at manifest validation instead.
851const RESERVED_PRESET_IDS: &[&str] = &["general_assistant"];
852
853/// `[a-z0-9-_]`, non-empty, no leading/trailing separator, no `--`/`__` runs
854/// are NOT specifically forbidden (unlike skill ids) since plugin ids may
855/// legitimately contain underscores (e.g. ported from an npm-style package
856/// name) — only characters and length are constrained.
857pub 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
865/// Same rule bamboo-server's prompt-preset store enforces
866/// (`validate_preset_id` in `handlers/agent/prompt_presets/storage.rs`):
867/// `[a-z0-9_]`, length <= 80 — plus a rejection of ids bamboo-server reserves
868/// (see [`RESERVED_PRESET_IDS`]) so a plugin can't declare one that would be
869/// silently dropped later.
870pub 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
879/// A conservative, dependency-free semver *shape* check: `N.N.N` with
880/// optional `-pre` / `+build` suffixes. Doesn't validate pre-release/build
881/// identifier grammar precisely — good enough to reject obviously-wrong
882/// strings (`"latest"`, `""`, `"1.0"`) without adding a `semver` dependency to
883/// a foundation crate. The installer can layer stricter comparison later.
884pub 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
893/// Rejects path separators, `..` traversal, empty strings, and control
894/// characters — used for both `provides.skills` dir names and
895/// `provides.workflows` filenames (which additionally must end in `.md`).
896fn 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    /// Parse a manifest from a `plugin.json` file's contents. Does not
906    /// validate — call [`Self::validate`] separately (parse vs. validate are
907    /// kept distinct so a caller can inspect an invalid-but-parseable
908    /// manifest, e.g. to report a precise validation error).
909    pub fn parse_str(content: &str) -> PluginResult<Self> {
910        serde_json::from_str(content).map_err(PluginError::from)
911    }
912
913    /// Structural validation beyond what serde already enforces. Does not
914    /// touch disk (skill-dir / workflow-file *existence* checks happen at
915    /// install time against a concrete `plugin_dir`, not here).
916    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            // Services are the highest-trust artifact kind: no PATH
993            // resolution, no ambient binaries. `command` must be EXACTLY the
994            // substitution token, never a literal path or shell command —
995            // stricter than MCP's free-form stdio `command`.
996            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            // Future versions preserve bounded opaque subscription/permission
1232            // strings and reconcile inactive. Only implemented v1 values are
1233            // interpreted here.
1234            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            // An artifact for a platform this plugin does not claim to support
1336            // is dead weight at best and a sign of a mistake at worst — reject
1337            // it so the manifest can't drift out of sync with its own gate.
1338            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        // Binary-backed URL install: if a per-platform binary is needed
1364        // (`${platform_bin}` is used) AND this manifest ships downloadable
1365        // `artifacts` (the URL-install path), then every platform the plugin
1366        // claims to support MUST have an artifact — otherwise the install
1367        // would fail opaquely on that OS at runtime (no binary to place under
1368        // `bin/`) instead of here, at manifest validation. Local-dir/archive
1369        // installs ship `bin/` directly and declare no `artifacts`, so this is
1370        // (correctly) skipped for them.
1371        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    /// Whether this manifest is installable on the given platform (`true` if
1388    /// `platforms` is unset — no restriction).
1389    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    /// The effective set of platforms this plugin claims to support: the
1397    /// `platforms` gate if present, otherwise all three (an unset gate means
1398    /// "all platforms").
1399    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    /// Whether any declared MCP stdio server references the `${platform_bin}`
1406    /// substitution token (in command, args, cwd, or an env value) — i.e.
1407    /// whether this plugin needs a per-platform binary to run. Drives the
1408    /// artifacts/platform cross-check in [`Self::validate`].
1409    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        // Services validate to command == PLATFORM_BIN_TOKEN exactly, but
1427        // this helper must stay correct even against a not-yet-validated
1428        // manifest (it feeds the artifacts/platform cross-check inside
1429        // `validate()` itself), so check `.contains` the same way MCP does
1430        // rather than assuming validity.
1431        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        // Uses ${platform_bin}, supports all three platforms (no gate), ships
1625        // URL artifacts — but only for macos + windows. Missing linux artifact
1626        // must be caught at validation, not at install time on a linux host.
1627        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        // Same plugin, but a `platforms` gate narrows support to exactly the
1651        // platforms that DO have artifacts → valid.
1652        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        // Local-dir/archive install: uses ${platform_bin} but ships bin/
1695        // directly (no `artifacts`). The cross-check must NOT fire.
1696        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        // Mirrors `rejects_platform_bin_plugin_missing_an_artifact_for_a_supported_platform`
2261        // but via `provides.services` instead of `provides.mcp_servers` — the
2262        // artifacts/platform cross-check must cover services too (issue #479).
2263        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}