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                    env_credential_refs: std::collections::HashMap::new(),
217                    startup_timeout_ms: default_startup_timeout(),
218                })
219            }
220            McpTransportManifest::Sse { url, headers } => TransportConfig::Sse(SseConfig {
221                url: url.clone(),
222                headers: headers.clone(),
223                connect_timeout_ms: default_connect_timeout(),
224            }),
225            McpTransportManifest::StreamableHttp { url, headers } => {
226                TransportConfig::StreamableHttp(StreamableHttpConfig {
227                    url: url.clone(),
228                    headers: headers.clone(),
229                    connect_timeout_ms: default_connect_timeout(),
230                })
231            }
232        };
233
234        Ok(McpServerConfig {
235            id: self.id.clone(),
236            name: self.name.clone(),
237            enabled: self.enabled,
238            transport,
239            request_timeout_ms: default_request_timeout(),
240            healthcheck_interval_ms: default_healthcheck_interval(),
241            reconnect: ReconnectConfig::default(),
242            allowed_tools: self.allowed_tools.clone(),
243            denied_tools: self.denied_tools.clone(),
244        })
245    }
246}
247
248/// Substitute `${plugin_dir}` and `${platform_bin}` in `template`. Unknown
249/// `${...}` tokens are left untouched (forward-compatible: a newer manifest
250/// using a token an older Bamboo doesn't know about degrades to a literal
251/// string rather than failing).
252pub fn substitute_tokens(
253    template: &str,
254    plugin_dir: &Path,
255    plugin_id: &str,
256    platform: Platform,
257) -> String {
258    let plugin_dir_str = plugin_dir.to_string_lossy();
259    let platform_bin_str = platform_bin_path(plugin_dir, plugin_id, platform)
260        .to_string_lossy()
261        .into_owned();
262    template
263        .replace("${plugin_dir}", plugin_dir_str.as_ref())
264        .replace("${platform_bin}", &platform_bin_str)
265}
266
267/// Resolve the fixed-convention per-platform binary path:
268/// `<plugin_dir>/bin/<platform>/<plugin_id>[.exe]`.
269pub fn platform_bin_path(plugin_dir: &Path, plugin_id: &str, platform: Platform) -> PathBuf {
270    let filename = if matches!(platform, Platform::Windows) {
271        format!("{plugin_id}.exe")
272    } else {
273        plugin_id.to_string()
274    };
275    plugin_dir
276        .join("bin")
277        .join(platform.as_str())
278        .join(filename)
279}
280
281/// Literal token a [`ServiceManifestEntry::command`] must equal EXACTLY (no
282/// PATH resolution, no ambient binaries — see [`ServiceManifestEntry`]'s
283/// docs and `PluginManifest::validate`). Also used by
284/// [`PluginManifest::uses_platform_bin_token`].
285pub const PLATFORM_BIN_TOKEN: &str = "${platform_bin}";
286
287/// How [`ServiceManager`](../../bamboo_server/service_manager/index.html)
288/// (bamboo-server) should poll a running service for liveness. `ProcessAlive`
289/// is the v1 default (no `target`); `Tcp`/`Http` additionally require a
290/// `target` (validated in [`PluginManifest::validate`]).
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum HealthCheckKind {
294    ProcessAlive,
295    Tcp,
296    Http,
297}
298
299fn default_health_interval_ms() -> u64 {
300    15_000
301}
302
303fn default_health_timeout_ms() -> u64 {
304    5_000
305}
306
307/// Health-check policy for a [`ServiceManifestEntry`].
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct HealthCheckSpec {
310    pub kind: HealthCheckKind,
311    /// Required (non-empty) for `Tcp` (`host:port`) / `Http` (a URL);
312    /// unused for `ProcessAlive`. Validated in [`PluginManifest::validate`].
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub target: Option<String>,
315    #[serde(default = "default_health_interval_ms")]
316    pub interval_ms: u64,
317    #[serde(default = "default_health_timeout_ms")]
318    pub timeout_ms: u64,
319}
320
321impl Default for HealthCheckSpec {
322    fn default() -> Self {
323        Self {
324            kind: HealthCheckKind::ProcessAlive,
325            target: None,
326            interval_ms: default_health_interval_ms(),
327            timeout_ms: default_health_timeout_ms(),
328        }
329    }
330}
331
332/// Signal a service's graceful shutdown sends before escalating to a hard
333/// kill. `Term` (SIGTERM on unix; a best-effort equivalent request on
334/// Windows before `TerminateProcess`) is the default; `None` skips the
335/// graceful signal entirely and kills immediately.
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum ShutdownSignal {
339    #[default]
340    Term,
341    None,
342}
343
344fn default_shutdown_timeout_ms() -> u64 {
345    5_000
346}
347
348/// Graceful-shutdown policy for a [`ServiceManifestEntry`].
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct GracefulShutdown {
351    #[serde(default)]
352    pub signal: ShutdownSignal,
353    /// How long to wait after `signal` before escalating to SIGKILL /
354    /// `TerminateProcess`.
355    #[serde(default = "default_shutdown_timeout_ms")]
356    pub timeout_ms: u64,
357}
358
359impl Default for GracefulShutdown {
360    fn default() -> Self {
361        Self {
362            signal: ShutdownSignal::default(),
363            timeout_ms: default_shutdown_timeout_ms(),
364        }
365    }
366}
367
368/// A long-running service this plugin wants supervised (issue #479, prereq
369/// for epic #477 — standalone connectors distributed as plugins). The
370/// highest-trust artifact kind a plugin can declare: unlike an MCP stdio
371/// server (whose `command` is free-form — see [`McpServerManifestEntry`]), a
372/// service's `command` MUST be exactly [`PLATFORM_BIN_TOKEN`] — no PATH
373/// resolution, no ambient binaries. It may only ever execute the plugin's
374/// own verified, sha256-pinned per-platform binary (see
375/// [`PluginArtifact`]'s archive contract) resolved via
376/// [`platform_bin_path`].
377///
378/// `args`/`cwd`/`env` (values only) accept the same `${plugin_dir}`/
379/// `${platform_bin}` substitution as MCP stdio entries — see
380/// [`substitute_tokens`].
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct ServiceManifestEntry {
383    /// Service id — becomes the key bamboo-server's `ServiceManager` and
384    /// provenance (`RegisteredCapabilities::service_ids`) key off.
385    pub id: String,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub name: Option<String>,
388    #[serde(default = "default_true")]
389    pub enabled: bool,
390    /// MUST validate as exactly [`PLATFORM_BIN_TOKEN`] — see the type docs.
391    pub command: String,
392    #[serde(default)]
393    pub args: Vec<String>,
394    /// May contain `${plugin_dir}` / `${platform_bin}`.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub cwd: Option<String>,
397    /// Values (not keys) may contain `${plugin_dir}` / `${platform_bin}`.
398    #[serde(default)]
399    pub env: HashMap<String, String>,
400    #[serde(default)]
401    pub health_check: HealthCheckSpec,
402    /// Reuses [`bamboo_domain::mcp_config::ReconnectConfig`]'s shape
403    /// (enabled/initial_backoff_ms/max_backoff_ms/max_attempts) per the
404    /// issue's design.
405    #[serde(default)]
406    pub restart_policy: bamboo_domain::mcp_config::ReconnectConfig,
407    #[serde(default)]
408    pub graceful_shutdown: GracefulShutdown,
409}
410
411/// A [`ServiceManifestEntry`] with all `${...}` tokens substituted and
412/// `command` resolved to the concrete per-platform binary path — pure, ready
413/// for bamboo-server's `ServiceManager` to spawn. Analogous to
414/// [`McpServerManifestEntry::resolve`]'s `McpServerConfig` output.
415#[derive(Debug, Clone)]
416pub struct ResolvedServiceEntry {
417    pub id: String,
418    pub name: Option<String>,
419    pub enabled: bool,
420    pub command: PathBuf,
421    pub args: Vec<String>,
422    pub cwd: Option<PathBuf>,
423    pub env: HashMap<String, String>,
424    pub health_check: HealthCheckSpec,
425    pub restart_policy: bamboo_domain::mcp_config::ReconnectConfig,
426    pub graceful_shutdown: GracefulShutdown,
427}
428
429impl ServiceManifestEntry {
430    /// Resolve this manifest entry against a concrete `plugin_dir`/platform.
431    /// Pure — does not touch disk, does not spawn anything. `command` is
432    /// always [`platform_bin_path`] (never `substitute_tokens`'d from
433    /// `self.command`) — validation already pins `self.command` to exactly
434    /// [`PLATFORM_BIN_TOKEN`], and `platform_bin_path` IS that token's
435    /// resolution.
436    pub fn resolve(
437        &self,
438        plugin_dir: &Path,
439        plugin_id: &str,
440        platform: Platform,
441    ) -> ResolvedServiceEntry {
442        ResolvedServiceEntry {
443            id: self.id.clone(),
444            name: self.name.clone(),
445            enabled: self.enabled,
446            command: platform_bin_path(plugin_dir, plugin_id, platform),
447            args: self
448                .args
449                .iter()
450                .map(|value| substitute_tokens(value, plugin_dir, plugin_id, platform))
451                .collect(),
452            cwd: self.cwd.as_deref().map(|value| {
453                PathBuf::from(substitute_tokens(value, plugin_dir, plugin_id, platform))
454            }),
455            env: self
456                .env
457                .iter()
458                .map(|(key, value)| {
459                    (
460                        key.clone(),
461                        substitute_tokens(value, plugin_dir, plugin_id, platform),
462                    )
463                })
464                .collect(),
465            health_check: self.health_check.clone(),
466            restart_policy: self.restart_policy.clone(),
467            graceful_shutdown: self.graceful_shutdown.clone(),
468        }
469    }
470}
471
472/// Inline prompt preset, mirroring bamboo-server's
473/// `StoredPromptPreset { id, name, description?, content }` (see
474/// `crates/app/bamboo-server/src/handlers/agent/prompt_presets/types.rs`).
475/// `id` must satisfy the same rule bamboo-server enforces:
476/// `[a-z0-9_]`, length <= 80 (see [`is_valid_preset_id`]).
477#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct PluginPromptPreset {
479    pub id: String,
480    pub name: String,
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub description: Option<String>,
483    pub content: String,
484}
485
486/// Per-platform downloadable artifact for the URL-install source (fetch logic
487/// is a later agent's job — this is schema-only).
488///
489/// # Archive contract (pinned for Wave-2 fetch code + plugin authors)
490///
491/// `url` points at an **archive**, never a raw executable: a `.zip` **or**
492/// `.tar.gz`/`.tgz`. The installer:
493/// 1. downloads it, verifies [`Self::sha256`] (lowercase hex, over the raw
494///    archive bytes) BEFORE unpacking anything,
495/// 2. unpacks it, and expects **exactly one executable at the archive root**
496///    named `<plugin id>` (unix) or `<plugin id>.exe` (windows),
497/// 3. places that executable at `<plugin_dir>/bin/<platform>/<plugin id>[.exe]`
498///    — the exact path [`platform_bin_path`] resolves, so `${platform_bin}`
499///    then points at it.
500///
501/// This matches how real release assets ship (e.g. nova's are
502/// `nova-v<ver>-<triple>.zip` with `nova.exe` at the zip root, and a
503/// `.tar.gz` with `nova` at the tar root) — a plugin does NOT have to
504/// re-layout its release binaries, it just declares the archive URL + hash.
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct PluginArtifact {
507    /// Archive URL (`.zip` / `.tar.gz` / `.tgz`) — see the type-level docs.
508    pub url: String,
509    /// Lowercase hex-encoded sha256 of the raw archive bytes, verified by the
510    /// installer after download and BEFORE unpacking.
511    pub sha256: String,
512}
513
514/// What a plugin provides: any subset of MCP servers, skills, prompt presets,
515/// and (future) workflows.
516#[derive(Debug, Clone, Default, Serialize, Deserialize)]
517pub struct PluginProvides {
518    #[serde(default, skip_serializing_if = "Vec::is_empty")]
519    pub mcp_servers: Vec<McpServerManifestEntry>,
520    /// Directory names under `<plugin_dir>/skills/`. Each must contain a
521    /// `SKILL.md`. These are discovered *in place* (no copy, no symlink) once
522    /// the skill-discovery extension picks up the plugin dir — see
523    /// `bamboo-skills`' `SkillDirectorySource::Plugin`. Declaring them here is
524    /// for provenance/validation, not for making discovery work.
525    #[serde(default, skip_serializing_if = "Vec::is_empty")]
526    pub skills: Vec<String>,
527    #[serde(default, skip_serializing_if = "Vec::is_empty")]
528    pub prompts: Vec<PluginPromptPreset>,
529    /// `.md` filenames under `<plugin_dir>/workflows/`. They remain in place
530    /// and are discovered as read-only legacy Skill adapters; installation
531    /// never copies them into a user's global workflow directory.
532    #[serde(default, skip_serializing_if = "Vec::is_empty")]
533    pub workflows: Vec<String>,
534    /// Long-running services this plugin wants supervised — see
535    /// [`ServiceManifestEntry`]. Issue #479 (prereq for epic #477).
536    #[serde(default, skip_serializing_if = "Vec::is_empty")]
537    pub services: Vec<ServiceManifestEntry>,
538}
539
540impl PluginProvides {
541    pub fn is_empty(&self) -> bool {
542        self.mcp_servers.is_empty()
543            && self.skills.is_empty()
544            && self.prompts.is_empty()
545            && self.workflows.is_empty()
546            && self.services.is_empty()
547    }
548}
549
550/// The `plugin.json` manifest.
551#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct PluginManifest {
553    /// Stable identifier, `[a-z0-9_-]`, used as the install directory name
554    /// (`~/.bamboo/plugins/<id>/`) and default binary name.
555    pub id: String,
556    pub name: String,
557    /// Semver-shaped string (`major.minor.patch[-pre][+build]`). Validated
558    /// structurally by [`PluginManifest::validate`]; actual semver comparison
559    /// for upgrade decisions is the installer's job (not depended on here to
560    /// avoid pulling in a semver crate for a foundation crate).
561    pub version: String,
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub description: Option<String>,
564    /// Minimum Bamboo version required, same shape as `version`.
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub bamboo_min_version: Option<String>,
567    /// Platform gate. `None` means "no restriction" (all platforms). `Some([])`
568    /// is rejected by [`PluginManifest::validate`] (an explicit empty gate
569    /// would mean "installable nowhere", which is never intended).
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub platforms: Option<Vec<Platform>>,
572    #[serde(default)]
573    pub provides: PluginProvides,
574    /// Per-platform downloadable bundle for the URL-install source. Keys are
575    /// the same lowercase strings as [`Platform::as_str`] (kept as `String`
576    /// rather than `Platform` here so an unknown/typo'd key surfaces as a
577    /// clear validation error instead of a silent serde failure on the whole
578    /// map — see [`PluginManifest::validate`]).
579    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
580    pub artifacts: HashMap<String, PluginArtifact>,
581}
582
583const MAX_PLUGIN_ID_LEN: usize = 64;
584const MAX_PRESET_ID_LEN: usize = 80;
585
586/// Preset ids the plugin system must NOT let a plugin claim, because
587/// bamboo-server reserves them. `"general_assistant"` is its
588/// `DEFAULT_PRESET_ID` (see
589/// `crates/app/bamboo-server/src/handlers/agent/prompt_presets/types.rs`);
590/// `sanitize_store` there silently STRIPS any stored preset with that id, so a
591/// plugin declaring it would pass a naive `[a-z0-9_]` check but then vanish at
592/// runtime with no error. Reject it up front at manifest validation instead.
593const RESERVED_PRESET_IDS: &[&str] = &["general_assistant"];
594
595/// `[a-z0-9-_]`, non-empty, no leading/trailing separator, no `--`/`__` runs
596/// are NOT specifically forbidden (unlike skill ids) since plugin ids may
597/// legitimately contain underscores (e.g. ported from an npm-style package
598/// name) — only characters and length are constrained.
599pub fn is_valid_plugin_id(id: &str) -> bool {
600    !id.is_empty()
601        && id.len() <= MAX_PLUGIN_ID_LEN
602        && id
603            .chars()
604            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
605}
606
607/// Same rule bamboo-server's prompt-preset store enforces
608/// (`validate_preset_id` in `handlers/agent/prompt_presets/storage.rs`):
609/// `[a-z0-9_]`, length <= 80 — plus a rejection of ids bamboo-server reserves
610/// (see [`RESERVED_PRESET_IDS`]) so a plugin can't declare one that would be
611/// silently dropped later.
612pub fn is_valid_preset_id(id: &str) -> bool {
613    !id.is_empty()
614        && id.len() <= MAX_PRESET_ID_LEN
615        && !RESERVED_PRESET_IDS.contains(&id)
616        && id
617            .chars()
618            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
619}
620
621/// A conservative, dependency-free semver *shape* check: `N.N.N` with
622/// optional `-pre` / `+build` suffixes. Doesn't validate pre-release/build
623/// identifier grammar precisely — good enough to reject obviously-wrong
624/// strings (`"latest"`, `""`, `"1.0"`) without adding a `semver` dependency to
625/// a foundation crate. The installer can layer stricter comparison later.
626pub fn is_plausible_semver(value: &str) -> bool {
627    let core = value.split(['-', '+']).next().unwrap_or_default();
628    let parts: Vec<&str> = core.split('.').collect();
629    parts.len() == 3
630        && parts
631            .iter()
632            .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
633}
634
635/// Rejects path separators, `..` traversal, empty strings, and control
636/// characters — used for both `provides.skills` dir names and
637/// `provides.workflows` filenames (which additionally must end in `.md`).
638fn is_safe_relative_name(name: &str) -> bool {
639    !name.is_empty()
640        && !name.contains('/')
641        && !name.contains('\\')
642        && !name.contains("..")
643        && !name.chars().any(|ch| ch.is_control())
644}
645
646impl PluginManifest {
647    /// Parse a manifest from a `plugin.json` file's contents. Does not
648    /// validate — call [`Self::validate`] separately (parse vs. validate are
649    /// kept distinct so a caller can inspect an invalid-but-parseable
650    /// manifest, e.g. to report a precise validation error).
651    pub fn parse_str(content: &str) -> PluginResult<Self> {
652        serde_json::from_str(content).map_err(PluginError::from)
653    }
654
655    /// Structural validation beyond what serde already enforces. Does not
656    /// touch disk (skill-dir / workflow-file *existence* checks happen at
657    /// install time against a concrete `plugin_dir`, not here).
658    pub fn validate(&self) -> PluginResult<()> {
659        if !is_valid_plugin_id(&self.id) {
660            return Err(PluginError::InvalidManifest(format!(
661                "invalid plugin id '{}': must be [a-z0-9-_], <= {} chars",
662                self.id, MAX_PLUGIN_ID_LEN
663            )));
664        }
665        if self.name.trim().is_empty() {
666            return Err(PluginError::InvalidManifest(
667                "plugin name must not be empty".to_string(),
668            ));
669        }
670        if !is_plausible_semver(&self.version) {
671            return Err(PluginError::InvalidManifest(format!(
672                "invalid plugin version '{}': expected major.minor.patch[-pre][+build]",
673                self.version
674            )));
675        }
676        if let Some(min_version) = &self.bamboo_min_version {
677            if !is_plausible_semver(min_version) {
678                return Err(PluginError::InvalidManifest(format!(
679                    "invalid bamboo_min_version '{min_version}'"
680                )));
681            }
682        }
683        if let Some(platforms) = &self.platforms {
684            if platforms.is_empty() {
685                return Err(PluginError::InvalidManifest(
686                    "platforms, if present, must not be empty (use `null`/omit for \"all platforms\")"
687                        .to_string(),
688                ));
689            }
690        }
691
692        let mut seen_mcp_ids = std::collections::HashSet::new();
693        for entry in &self.provides.mcp_servers {
694            if entry.id.trim().is_empty() {
695                return Err(PluginError::InvalidManifest(
696                    "mcp server entries must have a non-empty id".to_string(),
697                ));
698            }
699            if !seen_mcp_ids.insert(entry.id.clone()) {
700                return Err(PluginError::InvalidManifest(format!(
701                    "duplicate mcp server id '{}' in provides.mcp_servers",
702                    entry.id
703                )));
704            }
705            if let McpTransportManifest::Stdio { command, .. } = &entry.transport {
706                if command.trim().is_empty() {
707                    return Err(PluginError::InvalidManifest(format!(
708                        "mcp server '{}' has an empty stdio command",
709                        entry.id
710                    )));
711                }
712            }
713        }
714
715        let mut seen_service_ids = std::collections::HashSet::new();
716        for entry in &self.provides.services {
717            if entry.id.trim().is_empty() {
718                return Err(PluginError::InvalidManifest(
719                    "service entries must have a non-empty id".to_string(),
720                ));
721            }
722            if !seen_service_ids.insert(entry.id.clone()) {
723                return Err(PluginError::InvalidManifest(format!(
724                    "duplicate service id '{}' in provides.services",
725                    entry.id
726                )));
727            }
728            if entry.command.trim().is_empty() {
729                return Err(PluginError::InvalidManifest(format!(
730                    "service '{}' has an empty command",
731                    entry.id
732                )));
733            }
734            // Services are the highest-trust artifact kind: no PATH
735            // resolution, no ambient binaries. `command` must be EXACTLY the
736            // substitution token, never a literal path or shell command —
737            // stricter than MCP's free-form stdio `command`.
738            if entry.command != PLATFORM_BIN_TOKEN {
739                return Err(PluginError::InvalidManifest(format!(
740                    "service '{}' command must be exactly '{PLATFORM_BIN_TOKEN}' — services may \
741                     only execute the plugin's own verified per-platform binary, never an \
742                     arbitrary command",
743                    entry.id
744                )));
745            }
746            match entry.health_check.kind {
747                HealthCheckKind::Tcp | HealthCheckKind::Http => {
748                    let target_ok = entry
749                        .health_check
750                        .target
751                        .as_deref()
752                        .map(|value| !value.trim().is_empty())
753                        .unwrap_or(false);
754                    if !target_ok {
755                        return Err(PluginError::InvalidManifest(format!(
756                            "service '{}' health_check.kind={:?} requires a non-empty target",
757                            entry.id, entry.health_check.kind
758                        )));
759                    }
760                }
761                HealthCheckKind::ProcessAlive => {}
762            }
763        }
764
765        for skill_dir in &self.provides.skills {
766            if !is_safe_relative_name(skill_dir) {
767                return Err(PluginError::InvalidManifest(format!(
768                    "invalid skill directory name '{skill_dir}' in provides.skills"
769                )));
770            }
771        }
772
773        let mut seen_preset_ids = std::collections::HashSet::new();
774        for preset in &self.provides.prompts {
775            if !is_valid_preset_id(&preset.id) {
776                return Err(PluginError::InvalidManifest(format!(
777                    "invalid prompt preset id '{}': must be [a-z0-9_], <= {} chars",
778                    preset.id, MAX_PRESET_ID_LEN
779                )));
780            }
781            if !seen_preset_ids.insert(preset.id.clone()) {
782                return Err(PluginError::InvalidManifest(format!(
783                    "duplicate prompt preset id '{}' in provides.prompts",
784                    preset.id
785                )));
786            }
787            if preset.name.trim().is_empty() {
788                return Err(PluginError::InvalidManifest(format!(
789                    "prompt preset '{}' has an empty name",
790                    preset.id
791                )));
792            }
793            if preset.content.trim().is_empty() {
794                return Err(PluginError::InvalidManifest(format!(
795                    "prompt preset '{}' has empty content",
796                    preset.id
797                )));
798            }
799        }
800
801        for workflow_file in &self.provides.workflows {
802            if !is_safe_relative_name(workflow_file) || !workflow_file.ends_with(".md") {
803                return Err(PluginError::InvalidManifest(format!(
804                    "invalid workflow filename '{workflow_file}' in provides.workflows (must be a bare '<name>.md')"
805                )));
806            }
807        }
808
809        for (platform_key, artifact) in &self.artifacts {
810            let Some(artifact_platform) = Platform::parse(platform_key) else {
811                return Err(PluginError::InvalidManifest(format!(
812                    "unknown platform key '{platform_key}' in artifacts (expected macos/windows/linux)"
813                )));
814            };
815            // An artifact for a platform this plugin does not claim to support
816            // is dead weight at best and a sign of a mistake at worst — reject
817            // it so the manifest can't drift out of sync with its own gate.
818            if let Some(gate) = &self.platforms {
819                if !gate.contains(&artifact_platform) {
820                    return Err(PluginError::InvalidManifest(format!(
821                        "artifacts contains platform '{platform_key}' which is not in the \
822                         `platforms` gate {:?}",
823                        gate.iter()
824                            .map(|platform| platform.as_str())
825                            .collect::<Vec<_>>()
826                    )));
827                }
828            }
829            if artifact.url.trim().is_empty() {
830                return Err(PluginError::InvalidManifest(format!(
831                    "artifact for platform '{platform_key}' has an empty url"
832                )));
833            }
834            let sha_is_hex64 = artifact.sha256.len() == 64
835                && artifact.sha256.chars().all(|ch| ch.is_ascii_hexdigit());
836            if !sha_is_hex64 {
837                return Err(PluginError::InvalidManifest(format!(
838                    "artifact for platform '{platform_key}' has an invalid sha256 (expected 64 lowercase hex chars)"
839                )));
840            }
841        }
842
843        // Binary-backed URL install: if a per-platform binary is needed
844        // (`${platform_bin}` is used) AND this manifest ships downloadable
845        // `artifacts` (the URL-install path), then every platform the plugin
846        // claims to support MUST have an artifact — otherwise the install
847        // would fail opaquely on that OS at runtime (no binary to place under
848        // `bin/`) instead of here, at manifest validation. Local-dir/archive
849        // installs ship `bin/` directly and declare no `artifacts`, so this is
850        // (correctly) skipped for them.
851        if !self.artifacts.is_empty() && self.uses_platform_bin_token() {
852            for platform in self.effective_platforms() {
853                if !self.artifacts.contains_key(platform.as_str()) {
854                    return Err(PluginError::InvalidManifest(format!(
855                        "plugin uses ${{platform_bin}} and ships URL artifacts, but has no \
856                         artifact for supported platform '{}' (every supported platform needs a \
857                         downloadable binary bundle)",
858                        platform.as_str()
859                    )));
860                }
861            }
862        }
863
864        Ok(())
865    }
866
867    /// Whether this manifest is installable on the given platform (`true` if
868    /// `platforms` is unset — no restriction).
869    pub fn supports_platform(&self, platform: Platform) -> bool {
870        match &self.platforms {
871            None => true,
872            Some(platforms) => platforms.contains(&platform),
873        }
874    }
875
876    /// The effective set of platforms this plugin claims to support: the
877    /// `platforms` gate if present, otherwise all three (an unset gate means
878    /// "all platforms").
879    pub fn effective_platforms(&self) -> Vec<Platform> {
880        self.platforms
881            .clone()
882            .unwrap_or_else(|| vec![Platform::Macos, Platform::Windows, Platform::Linux])
883    }
884
885    /// Whether any declared MCP stdio server references the `${platform_bin}`
886    /// substitution token (in command, args, cwd, or an env value) — i.e.
887    /// whether this plugin needs a per-platform binary to run. Drives the
888    /// artifacts/platform cross-check in [`Self::validate`].
889    pub fn uses_platform_bin_token(&self) -> bool {
890        const TOKEN: &str = PLATFORM_BIN_TOKEN;
891        let mcp_uses = self.provides.mcp_servers.iter().any(|entry| {
892            let McpTransportManifest::Stdio {
893                command,
894                args,
895                cwd,
896                env,
897            } = &entry.transport
898            else {
899                return false;
900            };
901            command.contains(TOKEN)
902                || args.iter().any(|value| value.contains(TOKEN))
903                || cwd.as_deref().is_some_and(|value| value.contains(TOKEN))
904                || env.values().any(|value| value.contains(TOKEN))
905        });
906        // Services validate to command == PLATFORM_BIN_TOKEN exactly, but
907        // this helper must stay correct even against a not-yet-validated
908        // manifest (it feeds the artifacts/platform cross-check inside
909        // `validate()` itself), so check `.contains` the same way MCP does
910        // rather than assuming validity.
911        let service_uses = self.provides.services.iter().any(|entry| {
912            entry.command.contains(TOKEN)
913                || entry.args.iter().any(|value| value.contains(TOKEN))
914                || entry
915                    .cwd
916                    .as_deref()
917                    .is_some_and(|value| value.contains(TOKEN))
918                || entry.env.values().any(|value| value.contains(TOKEN))
919        });
920        mcp_uses || service_uses
921    }
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927
928    fn minimal_manifest_json() -> &'static str {
929        r#"{
930            "id": "hello-plugin",
931            "name": "Hello Plugin",
932            "version": "0.1.0",
933            "provides": {
934                "skills": ["hello-world"],
935                "prompts": [
936                    {"id": "hello_preset", "name": "Hello Preset", "content": "Say hello."}
937                ]
938            }
939        }"#
940    }
941
942    #[test]
943    fn parses_minimal_manifest() {
944        let manifest = PluginManifest::parse_str(minimal_manifest_json()).expect("parse");
945        assert_eq!(manifest.id, "hello-plugin");
946        assert_eq!(manifest.version, "0.1.0");
947        assert_eq!(manifest.provides.skills, vec!["hello-world".to_string()]);
948        assert_eq!(manifest.provides.prompts.len(), 1);
949        assert!(manifest.provides.mcp_servers.is_empty());
950        assert!(manifest.artifacts.is_empty());
951        manifest.validate().expect("minimal manifest is valid");
952    }
953
954    #[test]
955    fn parses_full_manifest_with_mcp_and_artifacts() {
956        let json = r#"{
957            "id": "nova_plugin",
958            "name": "Nova",
959            "version": "1.2.3-beta+build.7",
960            "description": "Desktop control MCP server",
961            "bamboo_min_version": "2026.7.0",
962            "platforms": ["macos", "windows", "linux"],
963            "provides": {
964                "mcp_servers": [
965                    {
966                        "id": "nova",
967                        "enabled": true,
968                        "transport": {
969                            "type": "stdio",
970                            "command": "${platform_bin}",
971                            "args": ["--serve"],
972                            "cwd": "${plugin_dir}",
973                            "env": {"NOVA_HOME": "${plugin_dir}/data"}
974                        }
975                    }
976                ],
977                "workflows": ["daily-report.md"]
978            },
979            "artifacts": {
980                "macos": {"url": "https://example.com/nova-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
981                "windows": {"url": "https://example.com/nova-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
982                "linux": {"url": "https://example.com/nova-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
983            }
984        }"#;
985
986        let manifest = PluginManifest::parse_str(json).expect("parse full manifest");
987        manifest.validate().expect("full manifest is valid");
988        assert!(manifest.supports_platform(Platform::Macos));
989        assert!(manifest.supports_platform(Platform::Windows));
990        assert!(manifest.supports_platform(Platform::Linux));
991
992        let entry = &manifest.provides.mcp_servers[0];
993        let plugin_dir = Path::new("/home/user/.bamboo/plugins/nova_plugin");
994        let resolved = entry
995            .resolve(plugin_dir, &manifest.id, Platform::Macos)
996            .expect("resolve mcp entry");
997        match resolved.transport {
998            bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
999                assert_eq!(
1000                    stdio.command,
1001                    "/home/user/.bamboo/plugins/nova_plugin/bin/macos/nova_plugin"
1002                );
1003                assert_eq!(stdio.cwd.as_deref(), Some(plugin_dir.to_str().unwrap()));
1004                assert_eq!(
1005                    stdio.env.get("NOVA_HOME").map(String::as_str),
1006                    Some("/home/user/.bamboo/plugins/nova_plugin/data")
1007                );
1008            }
1009            _ => panic!("expected stdio transport"),
1010        }
1011    }
1012
1013    #[test]
1014    fn platform_bin_path_appends_exe_on_windows_only() {
1015        let dir = Path::new("/plugins/demo");
1016        assert_eq!(
1017            platform_bin_path(dir, "demo", Platform::Macos),
1018            PathBuf::from("/plugins/demo/bin/macos/demo")
1019        );
1020        assert_eq!(
1021            platform_bin_path(dir, "demo", Platform::Windows),
1022            PathBuf::from("/plugins/demo/bin/windows/demo.exe")
1023        );
1024        assert_eq!(
1025            platform_bin_path(dir, "demo", Platform::Linux),
1026            PathBuf::from("/plugins/demo/bin/linux/demo")
1027        );
1028    }
1029
1030    #[test]
1031    fn rejects_invalid_id() {
1032        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1033        manifest.id = "Bad Id!".to_string();
1034        let error = manifest.validate().expect_err("bad id should fail");
1035        assert!(error.to_string().contains("invalid plugin id"));
1036    }
1037
1038    #[test]
1039    fn rejects_bad_semver() {
1040        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1041        manifest.version = "latest".to_string();
1042        let error = manifest.validate().expect_err("bad version should fail");
1043        assert!(error.to_string().contains("invalid plugin version"));
1044    }
1045
1046    #[test]
1047    fn rejects_empty_platforms_list() {
1048        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1049        manifest.platforms = Some(vec![]);
1050        let error = manifest
1051            .validate()
1052            .expect_err("empty platforms should fail");
1053        assert!(error.to_string().contains("platforms"));
1054    }
1055
1056    #[test]
1057    fn rejects_duplicate_mcp_server_ids() {
1058        let json = r#"{
1059            "id": "dup",
1060            "name": "Dup",
1061            "version": "1.0.0",
1062            "provides": {
1063                "mcp_servers": [
1064                    {"id": "a", "transport": {"type": "stdio", "command": "x"}},
1065                    {"id": "a", "transport": {"type": "stdio", "command": "y"}}
1066                ]
1067            }
1068        }"#;
1069        let manifest = PluginManifest::parse_str(json).unwrap();
1070        let error = manifest
1071            .validate()
1072            .expect_err("duplicate mcp id should fail");
1073        assert!(error.to_string().contains("duplicate mcp server id"));
1074    }
1075
1076    #[test]
1077    fn rejects_traversal_in_skill_dir_and_bad_workflow_filename() {
1078        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1079        manifest.provides.skills = vec!["../escape".to_string()];
1080        assert!(manifest.validate().is_err());
1081
1082        let mut manifest2: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1083        manifest2.provides.skills = vec![];
1084        manifest2.provides.workflows = vec!["not-markdown.txt".to_string()];
1085        assert!(manifest2.validate().is_err());
1086    }
1087
1088    #[test]
1089    fn rejects_invalid_artifact_sha256() {
1090        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1091        manifest.artifacts.insert(
1092            "macos".to_string(),
1093            PluginArtifact {
1094                url: "https://example.com/x.tar.gz".to_string(),
1095                sha256: "not-hex".to_string(),
1096            },
1097        );
1098        let error = manifest.validate().expect_err("bad sha256 should fail");
1099        assert!(error.to_string().contains("sha256"));
1100    }
1101
1102    #[test]
1103    fn rejects_platform_bin_plugin_missing_an_artifact_for_a_supported_platform() {
1104        // Uses ${platform_bin}, supports all three platforms (no gate), ships
1105        // URL artifacts — but only for macos + windows. Missing linux artifact
1106        // must be caught at validation, not at install time on a linux host.
1107        let json = r#"{
1108            "id": "binbacked",
1109            "name": "Bin Backed",
1110            "version": "1.0.0",
1111            "provides": {
1112                "mcp_servers": [
1113                    {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1114                ]
1115            },
1116            "artifacts": {
1117                "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1118                "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1119            }
1120        }"#;
1121        let manifest = PluginManifest::parse_str(json).unwrap();
1122        let error = manifest
1123            .validate()
1124            .expect_err("missing linux artifact should fail");
1125        assert!(error.to_string().contains("linux"));
1126    }
1127
1128    #[test]
1129    fn platform_bin_plugin_is_valid_when_gate_narrows_to_covered_platforms() {
1130        // Same plugin, but a `platforms` gate narrows support to exactly the
1131        // platforms that DO have artifacts → valid.
1132        let json = r#"{
1133            "id": "binbacked",
1134            "name": "Bin Backed",
1135            "version": "1.0.0",
1136            "platforms": ["macos", "windows"],
1137            "provides": {
1138                "mcp_servers": [
1139                    {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1140                ]
1141            },
1142            "artifacts": {
1143                "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1144                "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1145            }
1146        }"#;
1147        let manifest = PluginManifest::parse_str(json).unwrap();
1148        manifest
1149            .validate()
1150            .expect("gate-narrowed binary plugin is valid");
1151        assert!(manifest.uses_platform_bin_token());
1152    }
1153
1154    #[test]
1155    fn rejects_artifact_for_platform_outside_the_gate() {
1156        let json = r#"{
1157            "id": "gated",
1158            "name": "Gated",
1159            "version": "1.0.0",
1160            "platforms": ["macos"],
1161            "artifacts": {
1162                "linux": {"url": "https://example.com/x-linux.tar.gz", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}
1163            }
1164        }"#;
1165        let manifest = PluginManifest::parse_str(json).unwrap();
1166        let error = manifest
1167            .validate()
1168            .expect_err("artifact outside gate should fail");
1169        assert!(error.to_string().contains("not in the `platforms` gate"));
1170    }
1171
1172    #[test]
1173    fn local_install_with_platform_bin_and_no_artifacts_is_valid() {
1174        // Local-dir/archive install: uses ${platform_bin} but ships bin/
1175        // directly (no `artifacts`). The cross-check must NOT fire.
1176        let json = r#"{
1177            "id": "localbin",
1178            "name": "Local Bin",
1179            "version": "1.0.0",
1180            "provides": {
1181                "mcp_servers": [
1182                    {"id": "srv", "transport": {"type": "stdio", "command": "${platform_bin}"}}
1183                ]
1184            }
1185        }"#;
1186        let manifest = PluginManifest::parse_str(json).unwrap();
1187        manifest
1188            .validate()
1189            .expect("local binary plugin without artifacts is valid");
1190    }
1191
1192    #[test]
1193    fn rejects_reserved_preset_id() {
1194        let json = r#"{
1195            "id": "reserver",
1196            "name": "Reserver",
1197            "version": "1.0.0",
1198            "provides": {
1199                "prompts": [
1200                    {"id": "general_assistant", "name": "Nope", "content": "x"}
1201                ]
1202            }
1203        }"#;
1204        let manifest = PluginManifest::parse_str(json).unwrap();
1205        let error = manifest
1206            .validate()
1207            .expect_err("reserved preset id should fail");
1208        assert!(error.to_string().contains("prompt preset id"));
1209        assert!(!is_valid_preset_id("general_assistant"));
1210    }
1211
1212    #[test]
1213    fn rejects_unknown_artifact_platform_key() {
1214        let mut manifest: PluginManifest = serde_json::from_str(minimal_manifest_json()).unwrap();
1215        manifest.artifacts.insert(
1216            "solaris".to_string(),
1217            PluginArtifact {
1218                url: "https://example.com/x.tar.gz".to_string(),
1219                sha256: "a".repeat(64),
1220            },
1221        );
1222        let error = manifest
1223            .validate()
1224            .expect_err("unknown platform key should fail");
1225        assert!(error.to_string().contains("unknown platform key"));
1226    }
1227
1228    #[test]
1229    fn semver_shape_check() {
1230        assert!(is_plausible_semver("1.2.3"));
1231        assert!(is_plausible_semver("1.2.3-beta.1"));
1232        assert!(is_plausible_semver("1.2.3+build.7"));
1233        assert!(is_plausible_semver("1.2.3-beta+build"));
1234        assert!(!is_plausible_semver("1.2"));
1235        assert!(!is_plausible_semver("latest"));
1236        assert!(!is_plausible_semver(""));
1237        assert!(!is_plausible_semver("v1.2.3"));
1238    }
1239
1240    fn service_manifest_json(id: &str, command: &str) -> String {
1241        serde_json::json!({
1242            "id": "svc-plugin",
1243            "name": "Svc Plugin",
1244            "version": "1.0.0",
1245            "provides": {
1246                "services": [
1247                    {"id": id, "command": command}
1248                ]
1249            }
1250        })
1251        .to_string()
1252    }
1253
1254    #[test]
1255    fn parses_and_validates_minimal_service_entry() {
1256        let json = service_manifest_json("svc", PLATFORM_BIN_TOKEN);
1257        let manifest = PluginManifest::parse_str(&json).unwrap();
1258        manifest.validate().expect("minimal service entry is valid");
1259        let entry = &manifest.provides.services[0];
1260        assert!(entry.enabled);
1261        assert_eq!(entry.health_check.kind, HealthCheckKind::ProcessAlive);
1262        assert_eq!(entry.graceful_shutdown.signal, ShutdownSignal::Term);
1263        assert!(manifest.uses_platform_bin_token());
1264    }
1265
1266    #[test]
1267    fn rejects_service_command_that_is_not_exactly_the_platform_bin_token() {
1268        for bad_command in ["/usr/bin/env", "nova", "${platform_bin} --serve", ""] {
1269            let json = service_manifest_json("svc", bad_command);
1270            let manifest = PluginManifest::parse_str(&json).unwrap();
1271            let error = manifest
1272                .validate()
1273                .expect_err("non-token service command must be rejected");
1274            assert!(matches!(error, PluginError::InvalidManifest(_)));
1275        }
1276    }
1277
1278    #[test]
1279    fn rejects_duplicate_service_ids() {
1280        let json = serde_json::json!({
1281            "id": "svc-plugin",
1282            "name": "Svc",
1283            "version": "1.0.0",
1284            "provides": {
1285                "services": [
1286                    {"id": "a", "command": PLATFORM_BIN_TOKEN},
1287                    {"id": "a", "command": PLATFORM_BIN_TOKEN}
1288                ]
1289            }
1290        })
1291        .to_string();
1292        let manifest = PluginManifest::parse_str(&json).unwrap();
1293        let error = manifest
1294            .validate()
1295            .expect_err("duplicate service id should fail");
1296        assert!(error.to_string().contains("duplicate service id"));
1297    }
1298
1299    #[test]
1300    fn rejects_tcp_and_http_health_check_missing_target() {
1301        for kind in ["tcp", "http"] {
1302            let json = serde_json::json!({
1303                "id": "svc-plugin",
1304                "name": "Svc",
1305                "version": "1.0.0",
1306                "provides": {
1307                    "services": [
1308                        {"id": "a", "command": PLATFORM_BIN_TOKEN, "health_check": {"kind": kind}}
1309                    ]
1310                }
1311            })
1312            .to_string();
1313            let manifest = PluginManifest::parse_str(&json).unwrap();
1314            let error = manifest
1315                .validate()
1316                .expect_err("tcp/http health_check without a target should fail");
1317            assert!(error.to_string().contains("target"));
1318        }
1319    }
1320
1321    #[test]
1322    fn accepts_tcp_health_check_with_target() {
1323        let json = serde_json::json!({
1324            "id": "svc-plugin",
1325            "name": "Svc",
1326            "version": "1.0.0",
1327            "provides": {
1328                "services": [
1329                    {"id": "a", "command": PLATFORM_BIN_TOKEN, "health_check": {"kind": "tcp", "target": "127.0.0.1:9000"}}
1330                ]
1331            }
1332        })
1333        .to_string();
1334        let manifest = PluginManifest::parse_str(&json).unwrap();
1335        manifest
1336            .validate()
1337            .expect("tcp health_check with target is valid");
1338    }
1339
1340    #[test]
1341    fn services_missing_artifact_for_a_supported_platform_is_rejected() {
1342        // Mirrors `rejects_platform_bin_plugin_missing_an_artifact_for_a_supported_platform`
1343        // but via `provides.services` instead of `provides.mcp_servers` — the
1344        // artifacts/platform cross-check must cover services too (issue #479).
1345        let json = serde_json::json!({
1346            "id": "svc-plugin",
1347            "name": "Svc",
1348            "version": "1.0.0",
1349            "provides": {
1350                "services": [
1351                    {"id": "a", "command": PLATFORM_BIN_TOKEN}
1352                ]
1353            },
1354            "artifacts": {
1355                "macos": {"url": "https://example.com/x-macos.tar.gz", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1356                "windows": {"url": "https://example.com/x-windows.zip", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
1357            }
1358        })
1359        .to_string();
1360        let manifest = PluginManifest::parse_str(&json).unwrap();
1361        let error = manifest
1362            .validate()
1363            .expect_err("missing linux artifact for a service-only plugin should fail");
1364        assert!(error.to_string().contains("linux"));
1365    }
1366
1367    #[test]
1368    fn resolve_service_entry_substitutes_tokens_and_pins_command_to_platform_bin() {
1369        let json = serde_json::json!({
1370            "id": "svc-plugin",
1371            "name": "Svc",
1372            "version": "1.0.0",
1373            "provides": {
1374                "services": [
1375                    {
1376                        "id": "a",
1377                        "command": PLATFORM_BIN_TOKEN,
1378                        "args": ["--config", "${plugin_dir}/data"],
1379                        "cwd": "${plugin_dir}",
1380                        "env": {"HOME_DIR": "${plugin_dir}/home"}
1381                    }
1382                ]
1383            }
1384        })
1385        .to_string();
1386        let manifest = PluginManifest::parse_str(&json).unwrap();
1387        manifest.validate().expect("valid");
1388        let entry = &manifest.provides.services[0];
1389        let plugin_dir = Path::new("/home/user/.bamboo/plugins/svc-plugin");
1390        let resolved = entry.resolve(plugin_dir, &manifest.id, Platform::Linux);
1391        assert_eq!(
1392            resolved.command,
1393            PathBuf::from("/home/user/.bamboo/plugins/svc-plugin/bin/linux/svc-plugin")
1394        );
1395        assert_eq!(
1396            resolved.args,
1397            vec![
1398                "--config".to_string(),
1399                "/home/user/.bamboo/plugins/svc-plugin/data".to_string()
1400            ]
1401        );
1402        assert_eq!(resolved.cwd, Some(plugin_dir.to_path_buf()));
1403        assert_eq!(
1404            resolved.env.get("HOME_DIR").map(String::as_str),
1405            Some("/home/user/.bamboo/plugins/svc-plugin/home")
1406        );
1407    }
1408
1409    #[test]
1410    fn plugin_id_rules() {
1411        assert!(is_valid_plugin_id("hello-plugin"));
1412        assert!(is_valid_plugin_id("nova_plugin_2"));
1413        assert!(!is_valid_plugin_id(""));
1414        assert!(!is_valid_plugin_id("Hello"));
1415        assert!(!is_valid_plugin_id("hello plugin"));
1416        assert!(!is_valid_plugin_id(&"a".repeat(MAX_PLUGIN_ID_LEN + 1)));
1417    }
1418}