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, and (future) workflows. This module defines that schema and a
6//! handful of pure, side-effect-free helpers (validation + `${...}` token
7//! substitution) that the installer (a later agent) builds on.
8//!
9//! # Directory layout convention
10//!
11//! ```text
12//! ~/.bamboo/plugins/<id>/
13//!   plugin.json          <- this manifest
14//!   skills/<skill-dir>/SKILL.md   (one or more, referenced by `provides.skills`)
15//!   prompts/              (optional; unused by the inline prompt design, see below)
16//!   workflows/<name>.md   (referenced by `provides.workflows`)
17//!   bin/<platform>/<id>[.exe]     (optional per-platform binary, see substitution contract)
18//! ```
19//!
20//! # Design decision: inline prompts, not file references
21//!
22//! `provides.prompts` is a `Vec<PluginPromptPreset>` with the preset content
23//! inlined directly in `plugin.json` (mirroring bamboo-server's
24//! `StoredPromptPreset { id, name, description?, content }`), rather than a list
25//! of filenames under `prompts/`. Rationale: prompt presets are small, and
26//! inlining keeps `plugin.json` a single self-contained source of truth the
27//! installer can validate and append into `prompt-presets.json` without a second
28//! file-read pass or an extra path-traversal surface. A future manifest version
29//! could add a file-reference variant if presets grow large enough to want
30//! external editing.
31//!
32//! # Substitution contract for `mcp_servers[].transport.stdio.{command,args,cwd,env}`
33//!
34//! Stdio MCP server commands may reference two tokens, resolved by the
35//! installer at install/registration time (see [`substitute_tokens`]):
36//!
37//! - `${plugin_dir}` — the absolute path to the installed plugin's root
38//!   directory (i.e. the directory containing `plugin.json`).
39//! - `${platform_bin}` — the absolute path to this plugin's per-platform
40//!   binary, resolved as `<plugin_dir>/bin/<platform>/<plugin id>[.exe on windows]`
41//!   where `<platform>` is one of `macos` | `windows` | `linux` (matching
42//!   [`Platform::as_str`]). This is a fixed naming convention (binary filename
43//!   == manifest `id`, `.exe` suffix only on Windows) so a single manifest
44//!   works across platforms without per-OS conditionals in `plugin.json` — if a
45//!   plugin needs a different binary name, it can still express that by joining
46//!   directly, e.g. `"${plugin_dir}/bin/${platform}/nova"` — but `${platform}`
47//!   alone is intentionally NOT provided as a token (see [`substitute_tokens`]
48//!   doc) to keep the contract to exactly two tokens.
49//!
50//! Tokens are substituted in `command`, each element of `args`, `cwd`, and each
51//! value in `env` (not env *keys*, and not in `url` for sse/streamable_http —
52//! remote endpoints have no plugin-local path to inject).
53
54use std::collections::HashMap;
55use std::path::{Path, PathBuf};
56
57use serde::{Deserialize, Serialize};
58
59use crate::error::{PluginError, PluginResult};
60
61/// Target OS gate / per-platform artifact key.
62///
63/// Kept as a 3-way enum (rather than a free-form string) so `platforms` /
64/// `${platform_bin}` resolution / artifact selection all agree on the exact
65/// same three spellings.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Platform {
69    Macos,
70    Windows,
71    Linux,
72}
73
74impl Platform {
75    /// The platform this process is currently running on, if it is one of the
76    /// three Bamboo supports. `None` for anything else (e.g. `freebsd`) — a
77    /// platform gate should treat that as "not supported" rather than guess.
78    pub fn current() -> Option<Platform> {
79        Self::parse(std::env::consts::OS)
80    }
81
82    pub fn as_str(self) -> &'static str {
83        match self {
84            Platform::Macos => "macos",
85            Platform::Windows => "windows",
86            Platform::Linux => "linux",
87        }
88    }
89
90    /// Parse the lowercase spelling used both in `plugin.json` and in
91    /// `std::env::consts::OS` (which already yields "macos"/"windows"/"linux").
92    pub fn parse(value: &str) -> Option<Platform> {
93        match value {
94            "macos" => Some(Platform::Macos),
95            "windows" => Some(Platform::Windows),
96            "linux" => Some(Platform::Linux),
97            _ => None,
98        }
99    }
100}
101
102impl std::fmt::Display for Platform {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.write_str(self.as_str())
105    }
106}
107
108/// A single MCP server this plugin wants to register, shaped like
109/// [`bamboo_domain::mcp_config::McpServerConfig`] but with `${plugin_dir}` /
110/// `${platform_bin}` tokens allowed in the stdio transport's path-shaped
111/// fields. See the module docs for the substitution contract.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct McpServerManifestEntry {
114    /// Server id — becomes the `mcpServers` map key once registered.
115    pub id: String,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub name: Option<String>,
118    #[serde(default = "default_true")]
119    pub enabled: bool,
120    pub transport: McpTransportManifest,
121    #[serde(default)]
122    pub allowed_tools: Vec<String>,
123    #[serde(default)]
124    pub denied_tools: Vec<String>,
125}
126
127fn default_true() -> bool {
128    true
129}
130
131/// Transport variants a manifest can declare. Mirrors
132/// [`bamboo_domain::mcp_config::TransportConfig`]'s three transports, minus
133/// the fields the installer fills in with sensible defaults at registration
134/// time (timeouts, reconnect policy) — a manifest author shouldn't need to
135/// know Bamboo's default timeout values.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(tag = "type", rename_all = "snake_case")]
138pub enum McpTransportManifest {
139    Stdio {
140        /// May contain `${plugin_dir}` / `${platform_bin}`.
141        command: String,
142        #[serde(default)]
143        args: Vec<String>,
144        /// May contain `${plugin_dir}` / `${platform_bin}`.
145        #[serde(default, skip_serializing_if = "Option::is_none")]
146        cwd: Option<String>,
147        /// Values (not keys) may contain `${plugin_dir}` / `${platform_bin}`.
148        #[serde(default)]
149        env: HashMap<String, String>,
150    },
151    Sse {
152        url: String,
153        #[serde(default)]
154        headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
155    },
156    #[serde(rename = "streamable_http")]
157    StreamableHttp {
158        url: String,
159        #[serde(default)]
160        headers: Vec<bamboo_domain::mcp_config::HeaderConfig>,
161    },
162}
163
164impl McpServerManifestEntry {
165    /// Resolve this manifest entry into a real
166    /// [`bamboo_domain::mcp_config::McpServerConfig`], substituting
167    /// `${plugin_dir}` / `${platform_bin}` tokens and filling in Bamboo's
168    /// standard defaults for timeouts/reconnect. Pure — does not touch disk,
169    /// does not start anything. The caller (installer) is responsible for
170    /// merging the result into `config.json` and calling
171    /// `mcp_manager.start_server`.
172    pub fn resolve(
173        &self,
174        plugin_dir: &Path,
175        plugin_id: &str,
176        platform: Platform,
177    ) -> PluginResult<bamboo_domain::mcp_config::McpServerConfig> {
178        use bamboo_domain::mcp_config::{
179            default_connect_timeout, default_healthcheck_interval, default_request_timeout,
180            default_startup_timeout, McpServerConfig, ReconnectConfig, SseConfig, StdioConfig,
181            StreamableHttpConfig, TransportConfig,
182        };
183
184        let transport = match &self.transport {
185            McpTransportManifest::Stdio {
186                command,
187                args,
188                cwd,
189                env,
190            } => {
191                if command.trim().is_empty() {
192                    return Err(PluginError::InvalidManifest(format!(
193                        "mcp server '{}' has an empty stdio command",
194                        self.id
195                    )));
196                }
197                TransportConfig::Stdio(StdioConfig {
198                    command: substitute_tokens(command, plugin_dir, plugin_id, platform),
199                    args: args
200                        .iter()
201                        .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform))
202                        .collect(),
203                    cwd: cwd
204                        .as_deref()
205                        .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform)),
206                    env: env
207                        .iter()
208                        .map(|(key, value)| {
209                            (
210                                key.clone(),
211                                substitute_tokens(value, plugin_dir, plugin_id, platform),
212                            )
213                        })
214                        .collect(),
215                    env_encrypted: HashMap::new(),
216                    startup_timeout_ms: default_startup_timeout(),
217                })
218            }
219            McpTransportManifest::Sse { url, headers } => TransportConfig::Sse(SseConfig {
220                url: url.clone(),
221                headers: headers.clone(),
222                connect_timeout_ms: default_connect_timeout(),
223            }),
224            McpTransportManifest::StreamableHttp { url, headers } => {
225                TransportConfig::StreamableHttp(StreamableHttpConfig {
226                    url: url.clone(),
227                    headers: headers.clone(),
228                    connect_timeout_ms: default_connect_timeout(),
229                })
230            }
231        };
232
233        Ok(McpServerConfig {
234            id: self.id.clone(),
235            name: self.name.clone(),
236            enabled: self.enabled,
237            transport,
238            request_timeout_ms: default_request_timeout(),
239            healthcheck_interval_ms: default_healthcheck_interval(),
240            reconnect: ReconnectConfig::default(),
241            allowed_tools: self.allowed_tools.clone(),
242            denied_tools: self.denied_tools.clone(),
243        })
244    }
245}
246
247/// Substitute `${plugin_dir}` and `${platform_bin}` in `template`. Unknown
248/// `${...}` tokens are left untouched (forward-compatible: a newer manifest
249/// using a token an older Bamboo doesn't know about degrades to a literal
250/// string rather than failing).
251pub fn substitute_tokens(
252    template: &str,
253    plugin_dir: &Path,
254    plugin_id: &str,
255    platform: Platform,
256) -> String {
257    let plugin_dir_str = plugin_dir.to_string_lossy();
258    let platform_bin_str = platform_bin_path(plugin_dir, plugin_id, platform)
259        .to_string_lossy()
260        .into_owned();
261    template
262        .replace("${plugin_dir}", plugin_dir_str.as_ref())
263        .replace("${platform_bin}", &platform_bin_str)
264}
265
266/// Resolve the fixed-convention per-platform binary path:
267/// `<plugin_dir>/bin/<platform>/<plugin_id>[.exe]`.
268pub fn platform_bin_path(plugin_dir: &Path, plugin_id: &str, platform: Platform) -> PathBuf {
269    let filename = if matches!(platform, Platform::Windows) {
270        format!("{plugin_id}.exe")
271    } else {
272        plugin_id.to_string()
273    };
274    plugin_dir
275        .join("bin")
276        .join(platform.as_str())
277        .join(filename)
278}
279
280/// Inline prompt preset, mirroring bamboo-server's
281/// `StoredPromptPreset { id, name, description?, content }` (see
282/// `crates/app/bamboo-server/src/handlers/agent/prompt_presets/types.rs`).
283/// `id` must satisfy the same rule bamboo-server enforces:
284/// `[a-z0-9_]`, length <= 80 (see [`is_valid_preset_id`]).
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct PluginPromptPreset {
287    pub id: String,
288    pub name: String,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub description: Option<String>,
291    pub content: String,
292}
293
294/// Per-platform downloadable artifact for the URL-install source (fetch logic
295/// is a later agent's job — this is schema-only).
296///
297/// # Archive contract (pinned for Wave-2 fetch code + plugin authors)
298///
299/// `url` points at an **archive**, never a raw executable: a `.zip` **or**
300/// `.tar.gz`/`.tgz`. The installer:
301/// 1. downloads it, verifies [`Self::sha256`] (lowercase hex, over the raw
302///    archive bytes) BEFORE unpacking anything,
303/// 2. unpacks it, and expects **exactly one executable at the archive root**
304///    named `<plugin id>` (unix) or `<plugin id>.exe` (windows),
305/// 3. places that executable at `<plugin_dir>/bin/<platform>/<plugin id>[.exe]`
306///    — the exact path [`platform_bin_path`] resolves, so `${platform_bin}`
307///    then points at it.
308///
309/// This matches how real release assets ship (e.g. nova's are
310/// `nova-v<ver>-<triple>.zip` with `nova.exe` at the zip root, and a
311/// `.tar.gz` with `nova` at the tar root) — a plugin does NOT have to
312/// re-layout its release binaries, it just declares the archive URL + hash.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct PluginArtifact {
315    /// Archive URL (`.zip` / `.tar.gz` / `.tgz`) — see the type-level docs.
316    pub url: String,
317    /// Lowercase hex-encoded sha256 of the raw archive bytes, verified by the
318    /// installer after download and BEFORE unpacking.
319    pub sha256: String,
320}
321
322/// What a plugin provides: any subset of MCP servers, skills, prompt presets,
323/// and (future) workflows.
324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
325pub struct PluginProvides {
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub mcp_servers: Vec<McpServerManifestEntry>,
328    /// Directory names under `<plugin_dir>/skills/`. Each must contain a
329    /// `SKILL.md`. These are discovered *in place* (no copy, no symlink) once
330    /// the skill-discovery extension picks up the plugin dir — see
331    /// `bamboo-skills`' `SkillDirectorySource::Plugin`. Declaring them here is
332    /// for provenance/validation, not for making discovery work.
333    #[serde(default, skip_serializing_if = "Vec::is_empty")]
334    pub skills: Vec<String>,
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub prompts: Vec<PluginPromptPreset>,
337    /// `.md` filenames under `<plugin_dir>/workflows/`, copied by the
338    /// installer into `bamboo_config::paths::workflows_dir()` at install time
339    /// (workflows have no discovery-dir mechanism, unlike skills).
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub workflows: Vec<String>,
342}
343
344impl PluginProvides {
345    pub fn is_empty(&self) -> bool {
346        self.mcp_servers.is_empty()
347            && self.skills.is_empty()
348            && self.prompts.is_empty()
349            && self.workflows.is_empty()
350    }
351}
352
353/// The `plugin.json` manifest.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct PluginManifest {
356    /// Stable identifier, `[a-z0-9_-]`, used as the install directory name
357    /// (`~/.bamboo/plugins/<id>/`) and default binary name.
358    pub id: String,
359    pub name: String,
360    /// Semver-shaped string (`major.minor.patch[-pre][+build]`). Validated
361    /// structurally by [`PluginManifest::validate`]; actual semver comparison
362    /// for upgrade decisions is the installer's job (not depended on here to
363    /// avoid pulling in a semver crate for a foundation crate).
364    pub version: String,
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub description: Option<String>,
367    /// Minimum Bamboo version required, same shape as `version`.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub bamboo_min_version: Option<String>,
370    /// Platform gate. `None` means "no restriction" (all platforms). `Some([])`
371    /// is rejected by [`PluginManifest::validate`] (an explicit empty gate
372    /// would mean "installable nowhere", which is never intended).
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub platforms: Option<Vec<Platform>>,
375    #[serde(default)]
376    pub provides: PluginProvides,
377    /// Per-platform downloadable bundle for the URL-install source. Keys are
378    /// the same lowercase strings as [`Platform::as_str`] (kept as `String`
379    /// rather than `Platform` here so an unknown/typo'd key surfaces as a
380    /// clear validation error instead of a silent serde failure on the whole
381    /// map — see [`PluginManifest::validate`]).
382    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
383    pub artifacts: HashMap<String, PluginArtifact>,
384}
385
386const MAX_PLUGIN_ID_LEN: usize = 64;
387const MAX_PRESET_ID_LEN: usize = 80;
388
389/// Preset ids the plugin system must NOT let a plugin claim, because
390/// bamboo-server reserves them. `"general_assistant"` is its
391/// `DEFAULT_PRESET_ID` (see
392/// `crates/app/bamboo-server/src/handlers/agent/prompt_presets/types.rs`);
393/// `sanitize_store` there silently STRIPS any stored preset with that id, so a
394/// plugin declaring it would pass a naive `[a-z0-9_]` check but then vanish at
395/// runtime with no error. Reject it up front at manifest validation instead.
396const RESERVED_PRESET_IDS: &[&str] = &["general_assistant"];
397
398/// `[a-z0-9-_]`, non-empty, no leading/trailing separator, no `--`/`__` runs
399/// are NOT specifically forbidden (unlike skill ids) since plugin ids may
400/// legitimately contain underscores (e.g. ported from an npm-style package
401/// name) — only characters and length are constrained.
402pub fn is_valid_plugin_id(id: &str) -> bool {
403    !id.is_empty()
404        && id.len() <= MAX_PLUGIN_ID_LEN
405        && id
406            .chars()
407            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
408}
409
410/// Same rule bamboo-server's prompt-preset store enforces
411/// (`validate_preset_id` in `handlers/agent/prompt_presets/storage.rs`):
412/// `[a-z0-9_]`, length <= 80 — plus a rejection of ids bamboo-server reserves
413/// (see [`RESERVED_PRESET_IDS`]) so a plugin can't declare one that would be
414/// silently dropped later.
415pub fn is_valid_preset_id(id: &str) -> bool {
416    !id.is_empty()
417        && id.len() <= MAX_PRESET_ID_LEN
418        && !RESERVED_PRESET_IDS.contains(&id)
419        && id
420            .chars()
421            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
422}
423
424/// A conservative, dependency-free semver *shape* check: `N.N.N` with
425/// optional `-pre` / `+build` suffixes. Doesn't validate pre-release/build
426/// identifier grammar precisely — good enough to reject obviously-wrong
427/// strings (`"latest"`, `""`, `"1.0"`) without adding a `semver` dependency to
428/// a foundation crate. The installer can layer stricter comparison later.
429pub fn is_plausible_semver(value: &str) -> bool {
430    let core = value.split(['-', '+']).next().unwrap_or_default();
431    let parts: Vec<&str> = core.split('.').collect();
432    parts.len() == 3
433        && parts
434            .iter()
435            .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
436}
437
438/// Rejects path separators, `..` traversal, empty strings, and control
439/// characters — used for both `provides.skills` dir names and
440/// `provides.workflows` filenames (which additionally must end in `.md`).
441fn is_safe_relative_name(name: &str) -> bool {
442    !name.is_empty()
443        && !name.contains('/')
444        && !name.contains('\\')
445        && !name.contains("..")
446        && !name.chars().any(|ch| ch.is_control())
447}
448
449impl PluginManifest {
450    /// Parse a manifest from a `plugin.json` file's contents. Does not
451    /// validate — call [`Self::validate`] separately (parse vs. validate are
452    /// kept distinct so a caller can inspect an invalid-but-parseable
453    /// manifest, e.g. to report a precise validation error).
454    pub fn parse_str(content: &str) -> PluginResult<Self> {
455        serde_json::from_str(content).map_err(PluginError::from)
456    }
457
458    /// Structural validation beyond what serde already enforces. Does not
459    /// touch disk (skill-dir / workflow-file *existence* checks happen at
460    /// install time against a concrete `plugin_dir`, not here).
461    pub fn validate(&self) -> PluginResult<()> {
462        if !is_valid_plugin_id(&self.id) {
463            return Err(PluginError::InvalidManifest(format!(
464                "invalid plugin id '{}': must be [a-z0-9-_], <= {} chars",
465                self.id, MAX_PLUGIN_ID_LEN
466            )));
467        }
468        if self.name.trim().is_empty() {
469            return Err(PluginError::InvalidManifest(
470                "plugin name must not be empty".to_string(),
471            ));
472        }
473        if !is_plausible_semver(&self.version) {
474            return Err(PluginError::InvalidManifest(format!(
475                "invalid plugin version '{}': expected major.minor.patch[-pre][+build]",
476                self.version
477            )));
478        }
479        if let Some(min_version) = &self.bamboo_min_version {
480            if !is_plausible_semver(min_version) {
481                return Err(PluginError::InvalidManifest(format!(
482                    "invalid bamboo_min_version '{min_version}'"
483                )));
484            }
485        }
486        if let Some(platforms) = &self.platforms {
487            if platforms.is_empty() {
488                return Err(PluginError::InvalidManifest(
489                    "platforms, if present, must not be empty (use `null`/omit for \"all platforms\")"
490                        .to_string(),
491                ));
492            }
493        }
494
495        let mut seen_mcp_ids = std::collections::HashSet::new();
496        for entry in &self.provides.mcp_servers {
497            if entry.id.trim().is_empty() {
498                return Err(PluginError::InvalidManifest(
499                    "mcp server entries must have a non-empty id".to_string(),
500                ));
501            }
502            if !seen_mcp_ids.insert(entry.id.clone()) {
503                return Err(PluginError::InvalidManifest(format!(
504                    "duplicate mcp server id '{}' in provides.mcp_servers",
505                    entry.id
506                )));
507            }
508            if let McpTransportManifest::Stdio { command, .. } = &entry.transport {
509                if command.trim().is_empty() {
510                    return Err(PluginError::InvalidManifest(format!(
511                        "mcp server '{}' has an empty stdio command",
512                        entry.id
513                    )));
514                }
515            }
516        }
517
518        for skill_dir in &self.provides.skills {
519            if !is_safe_relative_name(skill_dir) {
520                return Err(PluginError::InvalidManifest(format!(
521                    "invalid skill directory name '{skill_dir}' in provides.skills"
522                )));
523            }
524        }
525
526        let mut seen_preset_ids = std::collections::HashSet::new();
527        for preset in &self.provides.prompts {
528            if !is_valid_preset_id(&preset.id) {
529                return Err(PluginError::InvalidManifest(format!(
530                    "invalid prompt preset id '{}': must be [a-z0-9_], <= {} chars",
531                    preset.id, MAX_PRESET_ID_LEN
532                )));
533            }
534            if !seen_preset_ids.insert(preset.id.clone()) {
535                return Err(PluginError::InvalidManifest(format!(
536                    "duplicate prompt preset id '{}' in provides.prompts",
537                    preset.id
538                )));
539            }
540            if preset.name.trim().is_empty() {
541                return Err(PluginError::InvalidManifest(format!(
542                    "prompt preset '{}' has an empty name",
543                    preset.id
544                )));
545            }
546            if preset.content.trim().is_empty() {
547                return Err(PluginError::InvalidManifest(format!(
548                    "prompt preset '{}' has empty content",
549                    preset.id
550                )));
551            }
552        }
553
554        for workflow_file in &self.provides.workflows {
555            if !is_safe_relative_name(workflow_file) || !workflow_file.ends_with(".md") {
556                return Err(PluginError::InvalidManifest(format!(
557                    "invalid workflow filename '{workflow_file}' in provides.workflows (must be a bare '<name>.md')"
558                )));
559            }
560        }
561
562        for (platform_key, artifact) in &self.artifacts {
563            let Some(artifact_platform) = Platform::parse(platform_key) else {
564                return Err(PluginError::InvalidManifest(format!(
565                    "unknown platform key '{platform_key}' in artifacts (expected macos/windows/linux)"
566                )));
567            };
568            // An artifact for a platform this plugin does not claim to support
569            // is dead weight at best and a sign of a mistake at worst — reject
570            // it so the manifest can't drift out of sync with its own gate.
571            if let Some(gate) = &self.platforms {
572                if !gate.contains(&artifact_platform) {
573                    return Err(PluginError::InvalidManifest(format!(
574                        "artifacts contains platform '{platform_key}' which is not in the \
575                         `platforms` gate {:?}",
576                        gate.iter()
577                            .map(|platform| platform.as_str())
578                            .collect::<Vec<_>>()
579                    )));
580                }
581            }
582            if artifact.url.trim().is_empty() {
583                return Err(PluginError::InvalidManifest(format!(
584                    "artifact for platform '{platform_key}' has an empty url"
585                )));
586            }
587            let sha_is_hex64 = artifact.sha256.len() == 64
588                && artifact.sha256.chars().all(|ch| ch.is_ascii_hexdigit());
589            if !sha_is_hex64 {
590                return Err(PluginError::InvalidManifest(format!(
591                    "artifact for platform '{platform_key}' has an invalid sha256 (expected 64 lowercase hex chars)"
592                )));
593            }
594        }
595
596        // Binary-backed URL install: if a per-platform binary is needed
597        // (`${platform_bin}` is used) AND this manifest ships downloadable
598        // `artifacts` (the URL-install path), then every platform the plugin
599        // claims to support MUST have an artifact — otherwise the install
600        // would fail opaquely on that OS at runtime (no binary to place under
601        // `bin/`) instead of here, at manifest validation. Local-dir/archive
602        // installs ship `bin/` directly and declare no `artifacts`, so this is
603        // (correctly) skipped for them.
604        if !self.artifacts.is_empty() && self.uses_platform_bin_token() {
605            for platform in self.effective_platforms() {
606                if !self.artifacts.contains_key(platform.as_str()) {
607                    return Err(PluginError::InvalidManifest(format!(
608                        "plugin uses ${{platform_bin}} and ships URL artifacts, but has no \
609                         artifact for supported platform '{}' (every supported platform needs a \
610                         downloadable binary bundle)",
611                        platform.as_str()
612                    )));
613                }
614            }
615        }
616
617        Ok(())
618    }
619
620    /// Whether this manifest is installable on the given platform (`true` if
621    /// `platforms` is unset — no restriction).
622    pub fn supports_platform(&self, platform: Platform) -> bool {
623        match &self.platforms {
624            None => true,
625            Some(platforms) => platforms.contains(&platform),
626        }
627    }
628
629    /// The effective set of platforms this plugin claims to support: the
630    /// `platforms` gate if present, otherwise all three (an unset gate means
631    /// "all platforms").
632    pub fn effective_platforms(&self) -> Vec<Platform> {
633        self.platforms
634            .clone()
635            .unwrap_or_else(|| vec![Platform::Macos, Platform::Windows, Platform::Linux])
636    }
637
638    /// Whether any declared MCP stdio server references the `${platform_bin}`
639    /// substitution token (in command, args, cwd, or an env value) — i.e.
640    /// whether this plugin needs a per-platform binary to run. Drives the
641    /// artifacts/platform cross-check in [`Self::validate`].
642    pub fn uses_platform_bin_token(&self) -> bool {
643        const TOKEN: &str = "${platform_bin}";
644        self.provides.mcp_servers.iter().any(|entry| {
645            let McpTransportManifest::Stdio {
646                command,
647                args,
648                cwd,
649                env,
650            } = &entry.transport
651            else {
652                return false;
653            };
654            command.contains(TOKEN)
655                || args.iter().any(|value| value.contains(TOKEN))
656                || cwd.as_deref().is_some_and(|value| value.contains(TOKEN))
657                || env.values().any(|value| value.contains(TOKEN))
658        })
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    fn minimal_manifest_json() -> &'static str {
667        r#"{
668            "id": "hello-plugin",
669            "name": "Hello Plugin",
670            "version": "0.1.0",
671            "provides": {
672                "skills": ["hello-world"],
673                "prompts": [
674                    {"id": "hello_preset", "name": "Hello Preset", "content": "Say hello."}
675                ]
676            }
677        }"#
678    }
679
680    #[test]
681    fn parses_minimal_manifest() {
682        let manifest = PluginManifest::parse_str(minimal_manifest_json()).expect("parse");
683        assert_eq!(manifest.id, "hello-plugin");
684        assert_eq!(manifest.version, "0.1.0");
685        assert_eq!(manifest.provides.skills, vec!["hello-world".to_string()]);
686        assert_eq!(manifest.provides.prompts.len(), 1);
687        assert!(manifest.provides.mcp_servers.is_empty());
688        assert!(manifest.artifacts.is_empty());
689        manifest.validate().expect("minimal manifest is valid");
690    }
691
692    #[test]
693    fn parses_full_manifest_with_mcp_and_artifacts() {
694        let json = r#"{
695            "id": "nova_plugin",
696            "name": "Nova",
697            "version": "1.2.3-beta+build.7",
698            "description": "Desktop control MCP server",
699            "bamboo_min_version": "2026.7.0",
700            "platforms": ["macos", "windows", "linux"],
701            "provides": {
702                "mcp_servers": [
703                    {
704                        "id": "nova",
705                        "enabled": true,
706                        "transport": {
707                            "type": "stdio",
708                            "command": "${platform_bin}",
709                            "args": ["--serve"],
710                            "cwd": "${plugin_dir}",
711                            "env": {"NOVA_HOME": "${plugin_dir}/data"}
712                        }
713                    }
714                ],
715                "workflows": ["daily-report.md"]
716            },
717            "artifacts": {
718                "macos": {"url": "https://example.com/nova-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
719                "windows": {"url": "https://example.com/nova-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
720                "linux": {"url": "https://example.com/nova-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
721            }
722        }"#;
723
724        let manifest = PluginManifest::parse_str(json).expect("parse full manifest");
725        manifest.validate().expect("full manifest is valid");
726        assert!(manifest.supports_platform(Platform::Macos));
727        assert!(manifest.supports_platform(Platform::Windows));
728        assert!(manifest.supports_platform(Platform::Linux));
729
730        let entry = &manifest.provides.mcp_servers[0];
731        let plugin_dir = Path::new("/home/user/.bamboo/plugins/nova_plugin");
732        let resolved = entry
733            .resolve(plugin_dir, &manifest.id, Platform::Macos)
734            .expect("resolve mcp entry");
735        match resolved.transport {
736            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
737                assert_eq!(
738                    stdio.command,
739                    "/home/user/.bamboo/plugins/nova_plugin/bin/macos/nova_plugin"
740                );
741                assert_eq!(stdio.cwd.as_deref(), Some(plugin_dir.to_str().unwrap()));
742                assert_eq!(
743                    stdio.env.get("NOVA_HOME").map(String::as_str),
744                    Some("/home/user/.bamboo/plugins/nova_plugin/data")
745                );
746            }
747            _ => panic!("expected stdio transport"),
748        }
749    }
750
751    #[test]
752    fn platform_bin_path_appends_exe_on_windows_only() {
753        let dir = Path::new("/plugins/demo");
754        assert_eq!(
755            platform_bin_path(dir, "demo", Platform::Macos),
756            PathBuf::from("/plugins/demo/bin/macos/demo")
757        );
758        assert_eq!(
759            platform_bin_path(dir, "demo", Platform::Windows),
760            PathBuf::from("/plugins/demo/bin/windows/demo.exe")
761        );
762        assert_eq!(
763            platform_bin_path(dir, "demo", Platform::Linux),
764            PathBuf::from("/plugins/demo/bin/linux/demo")
765        );
766    }
767
768    #[test]
769    fn rejects_invalid_id() {
770        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
771        manifest.id = "Bad Id!".to_string();
772        let error = manifest.validate().expect_err("bad id should fail");
773        assert!(error.to_string().contains("invalid plugin id"));
774    }
775
776    #[test]
777    fn rejects_bad_semver() {
778        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
779        manifest.version = "latest".to_string();
780        let error = manifest.validate().expect_err("bad version should fail");
781        assert!(error.to_string().contains("invalid plugin version"));
782    }
783
784    #[test]
785    fn rejects_empty_platforms_list() {
786        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
787        manifest.platforms = Some(vec![]);
788        let error = manifest
789            .validate()
790            .expect_err("empty platforms should fail");
791        assert!(error.to_string().contains("platforms"));
792    }
793
794    #[test]
795    fn rejects_duplicate_mcp_server_ids() {
796        let json = r#"{
797            "id": "dup",
798            "name": "Dup",
799            "version": "1.0.0",
800            "provides": {
801                "mcp_servers": [
802                    {"id": "a", "transport": {"type": "stdio", "command": "x"}},
803                    {"id": "a", "transport": {"type": "stdio", "command": "y"}}
804                ]
805            }
806        }"#;
807        let manifest = PluginManifest::parse_str(json).unwrap();
808        let error = manifest
809            .validate()
810            .expect_err("duplicate mcp id should fail");
811        assert!(error.to_string().contains("duplicate mcp server id"));
812    }
813
814    #[test]
815    fn rejects_traversal_in_skill_dir_and_bad_workflow_filename() {
816        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
817        manifest.provides.skills = vec!["../escape".to_string()];
818        assert!(manifest.validate().is_err());
819
820        let mut manifest2: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
821        manifest2.provides.skills = vec![];
822        manifest2.provides.workflows = vec!["not-markdown.txt".to_string()];
823        assert!(manifest2.validate().is_err());
824    }
825
826    #[test]
827    fn rejects_invalid_artifact_sha256() {
828        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
829        manifest.artifacts.insert(
830            "macos".to_string(),
831            PluginArtifact {
832                url: "https://example.com/x.tar.gz".to_string(),
833                sha256: "not-hex".to_string(),
834            },
835        );
836        let error = manifest.validate().expect_err("bad sha256 should fail");
837        assert!(error.to_string().contains("sha256"));
838    }
839
840    #[test]
841    fn rejects_platform_bin_plugin_missing_an_artifact_for_a_supported_platform() {
842        // Uses ${platform_bin}, supports all three platforms (no gate), ships
843        // URL artifacts — but only for macos + windows. Missing linux artifact
844        // must be caught at validation, not at install time on a linux host.
845        let json = r#"{
846            "id": "binbacked",
847            "name": "Bin Backed",
848            "version": "1.0.0",
849            "provides": {
850                "mcp_servers": [
851                    {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
852                ]
853            },
854            "artifacts": {
855                "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
856                "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
857            }
858        }"#;
859        let manifest = PluginManifest::parse_str(json).unwrap();
860        let error = manifest
861            .validate()
862            .expect_err("missing linux artifact should fail");
863        assert!(error.to_string().contains("linux"));
864    }
865
866    #[test]
867    fn platform_bin_plugin_is_valid_when_gate_narrows_to_covered_platforms() {
868        // Same plugin, but a `platforms` gate narrows support to exactly the
869        // platforms that DO have artifacts → valid.
870        let json = r#"{
871            "id": "binbacked",
872            "name": "Bin Backed",
873            "version": "1.0.0",
874            "platforms": ["macos", "windows"],
875            "provides": {
876                "mcp_servers": [
877                    {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
878                ]
879            },
880            "artifacts": {
881                "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
882                "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
883            }
884        }"#;
885        let manifest = PluginManifest::parse_str(json).unwrap();
886        manifest
887            .validate()
888            .expect("gate-narrowed binary plugin is valid");
889        assert!(manifest.uses_platform_bin_token());
890    }
891
892    #[test]
893    fn rejects_artifact_for_platform_outside_the_gate() {
894        let json = r#"{
895            "id": "gated",
896            "name": "Gated",
897            "version": "1.0.0",
898            "platforms": ["macos"],
899            "artifacts": {
900                "linux": {"url": "https://example.com/x-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
901            }
902        }"#;
903        let manifest = PluginManifest::parse_str(json).unwrap();
904        let error = manifest
905            .validate()
906            .expect_err("artifact outside gate should fail");
907        assert!(error.to_string().contains("not in the `platforms` gate"));
908    }
909
910    #[test]
911    fn local_install_with_platform_bin_and_no_artifacts_is_valid() {
912        // Local-dir/archive install: uses ${platform_bin} but ships bin/
913        // directly (no `artifacts`). The cross-check must NOT fire.
914        let json = r#"{
915            "id": "localbin",
916            "name": "Local Bin",
917            "version": "1.0.0",
918            "provides": {
919                "mcp_servers": [
920                    {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
921                ]
922            }
923        }"#;
924        let manifest = PluginManifest::parse_str(json).unwrap();
925        manifest
926            .validate()
927            .expect("local binary plugin without artifacts is valid");
928    }
929
930    #[test]
931    fn rejects_reserved_preset_id() {
932        let json = r#"{
933            "id": "reserver",
934            "name": "Reserver",
935            "version": "1.0.0",
936            "provides": {
937                "prompts": [
938                    {"id": "general_assistant", "name": "Nope", "content": "x"}
939                ]
940            }
941        }"#;
942        let manifest = PluginManifest::parse_str(json).unwrap();
943        let error = manifest
944            .validate()
945            .expect_err("reserved preset id should fail");
946        assert!(error.to_string().contains("prompt preset id"));
947        assert!(!is_valid_preset_id("general_assistant"));
948    }
949
950    #[test]
951    fn rejects_unknown_artifact_platform_key() {
952        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
953        manifest.artifacts.insert(
954            "solaris".to_string(),
955            PluginArtifact {
956                url: "https://example.com/x.tar.gz".to_string(),
957                sha256: "a".repeat(64),
958            },
959        );
960        let error = manifest
961            .validate()
962            .expect_err("unknown platform key should fail");
963        assert!(error.to_string().contains("unknown platform key"));
964    }
965
966    #[test]
967    fn semver_shape_check() {
968        assert!(is_plausible_semver("1.2.3"));
969        assert!(is_plausible_semver("1.2.3-beta.1"));
970        assert!(is_plausible_semver("1.2.3+build.7"));
971        assert!(is_plausible_semver("1.2.3-beta+build"));
972        assert!(!is_plausible_semver("1.2"));
973        assert!(!is_plausible_semver("latest"));
974        assert!(!is_plausible_semver(""));
975        assert!(!is_plausible_semver("v1.2.3"));
976    }
977
978    #[test]
979    fn plugin_id_rules() {
980        assert!(is_valid_plugin_id("hello-plugin"));
981        assert!(is_valid_plugin_id("nova_plugin_2"));
982        assert!(!is_valid_plugin_id(""));
983        assert!(!is_valid_plugin_id("Hello"));
984        assert!(!is_valid_plugin_id("hello plugin"));
985        assert!(!is_valid_plugin_id(&"a".repeat(MAX_PLUGIN_ID_LEN + 1)));
986    }
987}