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